Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5.5 cf8d1cfaaa Version 0.14.0
Rattrapage d'AJEAN jusqu'à la 0.17.6 (contexte, reprise réseau, presets
externes, tâches ponctuelles, file d'attente) et reprises d'OpenFox pour le
mode Code (vérification quand le builder a fini, pré-vol des écritures,
relance des appels textuels, alias d'outils, consignes du dépôt, terminal).
Les 19 commits locaux de la synchro 0.13.6 → 0.16.3, restés hors de main,
y sont rejoués.

Bump des trois porteurs de version (constante Go, versioninfo.json, .syso
Windows régénérés), notes de release réécrites, NOTICE à jour des idées
reprises d'OpenFox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:41:37 +02:00
MichaelandClaude Opus 5.5 b955181bb9 Mode Code : une écriture interdite est refusée dès son chemin, pas après le fichier
Repris du pré-vol d'OpenFox (tool-preflight, 2.0.154).

Un write sur un fichier existant jamais lu était refusé par le tracker…
une fois tout le fichier généré. builder.md demande des fichiers écrits en
entier : un refus pouvait coûter des minutes de décodage pour rien.

- Les schémas write, edit, mem_add et mem_edit annoncent le chemin AVANT
  le contenu. Une map Go sort ses clés par ordre alphabétique : le modèle,
  qui suit l'ordre du schéma, déroulait tout le contenu avant le chemin.
  L'interface nomme aussi le fichier dès le début de la frappe.
- Dès que le chemin d'un write/edit est complet dans le flux, les gardes du
  mode Code (fichier lu, chemin permis) sont vérifiées. Refus : la
  génération est coupée, l'appel réduit à son chemin entre dans
  l'historique avec le refus pour résultat, et le modèle repart (lire le
  fichier d'abord). Rien n'est écrit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:40:38 +02:00
MichaelandClaude Opus 5.5 15495431fc Mode Code : le compactage garde les fichiers touchés, les erreurs et les critères
Repris du COMPACTION_PROMPT d'OpenFox.

Après un compactage, le builder réécrivait des fichiers déjà faits et
retombait dans des erreurs déjà résolues : le résumé, pensé pour le chat,
ne gardait ni l'un ni l'autre. Et les critères, qui ne vivent pas dans le
fil (seuls les appels à l'outil criteria y passent), disparaissaient avec
le torse résumé.

- En mode Code, le résumé garde aussi chaque fichier créé ou modifié, les
  erreurs rencontrées et leur résolution, et les commandes de build/test.
- L'état des critères est rendu au builder dans le message de résumé — un
  message de toute façon neuf, donc sans cache invalidé en plus.

(Au passage : gofmt sur code_git.go, mal aligné au commit précédent.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:38:07 +02:00
MichaelandClaude Opus 5.5 d5632e3595 Mode Code : les consignes du dépôt (AGENTS.md, CLAUDE.md) font partie du contexte
Repris de context/instructions.ts d'OpenFox.

Un dépôt qui documente ses commandes de build et de test, ses conventions
et ses pièges le fait dans AGENTS.md ou CLAUDE.md. Le modèle les
redécouvrait à coups de read et de bash — quand il les redécouvrait.

En mode Code, le premier de ces fichiers trouvé (dépôt cloné détecté, puis
racine de la discussion) part avec le contexte du projet, tronqué à 4 000
caractères. Comme le reste du contexte projet, il est déplacé dans le
premier message utilisateur : le préfixe système, et le cache de prompt,
restent intacts.

Les tours de correction et de relance du builder reçoivent désormais le
même contexte projet que le tour de build : sans lui, le début du premier
message utilisateur changeait et tout le prompt était recalculé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:36:51 +02:00
MichaelandClaude Opus 5.5 dfafca6fb2 Mode Code : git sur le dépôt cloné, budget de build, sous-agents moins rigides
Trois trous relevés en comparant avec OpenFox.

- git_status / git_diff tournaient à la racine de la discussion, qui
  n'est souvent pas un dépôt : git_clone range le projet dans un
  sous-dossier, et le vérificateur — dont la consigne commence par
  git_diff — tombait sur « not a git repository ». Ils visent maintenant
  le sous-dossier demandé (dir), la racine si c'est un dépôt, sinon
  l'unique dépôt présent ; plusieurs : on demande de choisir.
- Budget d'outils triplé en mode Code : lire, éditer, compiler, relancer
  les tests dépasse vite 24 appels sans tourner en rond, et le troisième
  rappel (« n'appelle plus d'outil ») coupait le build au milieu.
- Sous-agents : température 0.6 au lieu de 0 (en glouton, Qwen avec
  réflexion boucle vite ; le TEMP du preset l'emporte toujours), et seul
  le texte écrit après le dernier outil revient au builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:36:01 +02:00
MichaelandClaude Opus 5.5 2e0356cd6c Terminal : le vrai code de sortie, la sortie gardée au délai, sans ANSI
Repris de shell.ts / shell-tail.ts et diagnostics.ts d'OpenFox (2.0.160).

- « cmd | tail -N » : le code de sortie devenait celui de tail (0) et un
  build en échec passait pour réussi. Le tail est retiré de la commande et
  Loki garde lui-même les N dernières lignes : même sortie, vrai code.
- Délai dépassé : la sortie déjà produite est rendue (où en était le build,
  quel test bloquait) au lieu d'un « [timeout] » sec, avec le conseil de
  passer par bash_bg pour un serveur.
- Couleurs et séquences ANSI retirées : du bruit en tokens.
- La durée suit le code de sortie (« exit: 0 · 1.2s »).
- Mode Code : « cmd & » refusé, avec renvoi vers bash_bg — le process
  orphelin n'avait ni sortie lisible ni moyen d'être arrêté.
- Diagnostics LSP : erreurs d'abord, avec le compte total ; au-delà de la
  limite, des indices de style passaient devant l'erreur de compilation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:34:42 +02:00
MichaelandClaude Opus 5.5 a745d2399e Agent : read_file, str_replace, old_string… traduits vers les vrais outils
Généralisation du transformSubAgentAliases d'OpenFox. Les petits modèles
ont appris d'autres agents : read_file, str_replace, run_command, ou path /
old_string au lieu de file / old. L'appel tombait sur « outil inconnu »
ou sur un argument manquant, et le tour se perdait en allers-retours.

Le nom et les arguments sont traduits vers l'outil réel quand la cible est
disponible dans ce tour, avant que l'appel soit rangé dans l'historique
(le modèle y relit l'appel tel qu'exécuté). Un sous-agent appelé comme un
outil (« explorer », « code_reviewer ») devient subagent{role, task}. Un
appel déjà correct, ou dont la cible n'est pas offerte, reste intact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:32:29 +02:00
MichaelandClaude Opus 5.5 ad87f2fc9f Agent : un appel d'outil écrit en texte coupe le flux et la relance le cite
Repris d'OpenFox (stream-pure, agent-loop, 2.0.15x).

- Repéré PENDANT le flux (hors réflexion en ligne) : la génération est
  coupée tout de suite au lieu de laisser le modèle dérouler un faux appel
  — parfois un fichier entier — qui ne serait jamais exécuté.
- La consigne corrective cite l'extrait fautif : le modèle voit ce qu'il a
  mal écrit.
- Jusqu'à 3 relances consécutives au lieu d'une par tour ; le compteur
  repart à zéro après chaque appel émis par le protocole. Un long tour
  d'agent pouvait rater la syntaxe deux fois et finir sur un pseudo-appel.
- Nouveaux motifs : le format XML de Qwen3-Coder (<function=…>,
  <parameter=…>) quand le gabarit du moteur ne l'a pas converti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:31:33 +02:00
MichaelandClaude Opus 5.5 7b754c0cb6 Mode Code : vérifier quand le builder a fini, jamais pendant une question
Repris du buildAgentNudge d'OpenFox (2.0.133).

La passe de vérification partait à chaque fin de tour de build, y compris
quand Qwen s'arrêtait sur « ensuite je vais… » ou venait de poser une
question avec ask. Chaque passe inutile coûte un prefill complet, évince
le cache KV du fil, et produit des « failed » qui ne disent que « pas
encore fait » — en courant par-dessus la question restée sans réponse.

- Nouveau statut « completed » : le builder marque un critère fait une
  fois vérifié par lui-même. Seule la vérification marque passed/failed.
- Fin de tour avec des critères encore ouverts : le builder est relancé
  sur ces critères (deux fois au plus) avant toute vérification.
- Fin de tour sur une question (ask) : pas de vérification, ni de
  correction, avant la réponse de l'utilisateur ; idem si le builder pose
  une question pendant une correction.
- Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien
  le bon critère au lieu de #0.
- Le panneau des critères montre « completed » (◐).
- Test de non-régression de la passe de vérification (code_verify_test.go).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:30:00 +02:00
MichaelandClaude Opus 5.5 9ccdc021cb Chiffrement : la discussion active et le mode Code ne deviennent plus illisibles
Activer le chiffrement de la mémoire chiffrait TOUTES les valeurs du
bucket des discussions, y compris des clés que le code lit et écrit en
clair (getStr/putStr) : le pointeur de discussion active, le mode
Chat|Code de chaque discussion, la puce « passer en mode Code ». Relues
comme du charabia, la discussion active devenait introuvable et le mode
Code disparaissait. Les critères, lus en clair eux aussi, s'effaçaient.

- Ces clés-pointeurs restent en clair (plainStoreKey) : ni rechiffrées, ni
  comptées comme « chiffrement incomplet ».
- Celles qu'une base existante a déjà chiffrées sont remises en clair dès
  le déverrouillage, avant le rechargement de la discussion active.
- Les critères passent par putStoreBytes/getStoreBytes : chiffrés comme le
  reste du fil, et lisibles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:27:43 +02:00
MichaelandClaude Opus 5.5 5e36b1cf82 Discussions : la liste montre la nouvelle dès le premier message
Repris d'AJEAN 0.17.6. La discussion est enregistrée au début du tour
(StartTurn persiste), mais la liste ne se rafraîchissait qu'à la fin de
la réponse. Elle se recharge maintenant à l'arrivée du message, groupée
quand plusieurs événements se suivent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:32 +02:00
MichaelandClaude Opus 5.5 2bbf0ba76d Tâches : « rappelle-moi dans 20 minutes » sans cron, à l'heure du navigateur
Repris d'AJEAN 0.17.5.

- task_create accepte in_minutes (dans N minutes) ou at (« HH:MM », prochaine
  occurrence, ou « AAAA-MM-JJ HH:MM ») pour une tâche unique, calculée côté
  serveur : le modèle n'a plus à écrire un cron ni à jongler avec les
  fuseaux pour un simple rappel. Elle s'écrit « @once AAAA-MM-JJ HH:MM » et
  se désactive après son passage ; manquée pendant un arrêt, elle part au
  redémarrage.
- Le navigateur envoie son fuseau (Europe/Paris…) avec chaque message ; il
  est retenu et sert aux tâches que l'IA planifie. Le conteneur tourne en
  UTC : « à 17h » tombait deux heures à côté. La réponse de task_create
  nomme le fuseau utilisé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:04 +02:00
MichaelandClaude Opus 5.5 8171899ac1 Contexte : fini le 400 « dépasse la fenêtre » qui bloquait la discussion
Repris d'AJEAN 0.17.5. Sur une fenêtre de 65k, un tour qui lit plusieurs
gros fichiers d'un coup passait de 60 % à plus de 130 % sans jamais
compacter, et l'erreur remontait telle quelle.

- Le test de compactage en cours de tour compte aussi les résultats
  d'outils de l'étape, que le moteur n'a pas encore vus. Sans usage côté
  serveur, l'estimation porte toujours sur tout l'historique.
- Tout résultat d'outil est borné à 30 000 caractères dans la vue du
  modèle, avec une mention qui l'invite à cibler. L'interface garde le
  résultat complet (« voir plus »).
- Dernier recours quand le moteur refuse encore le prompt et que le
  compactage n'a rien pu faire : shrinkToFit tronque les gros résultats
  d'outils puis retire les plus vieux échanges, vers 60 % de la fenêtre
  corrigés par la taille réelle lue dans l'erreur. Deux essais au plus.
- Tâches planifiées : la note de tâche passe en tête du message utilisateur
  au lieu du système. Le préfixe reste celui du chat et le cache de prompt
  de llama-server sert aux deux (l'amont mesurait 12 s de recalcul pour un
  « salut » après une tâche).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:23:21 +02:00
MichaelandClaude Opus 5.5 199d7f1e99 Chat : compactage sans redite, file multi-appareils, bascule par identifiant
Repris d'AJEAN 0.17.4.

- Compactage : la dernière demande du torse n'est plus réinjectée quand la
  queue contient déjà un message utilisateur (le modèle répondait une
  seconde fois à une vieille question), ni quand le résumé a échoué (elle
  est déjà dans le torse dégraissé et réapparaissait après ses propres
  résultats d'outils).
- Le texte écrit avant un appel d'outil n'est plus rangé deux fois dans
  l'historique du modèle (message tool_calls ET réponse finale) : du
  contexte gaspillé à chaque tour d'outil.
- Deux appareils qui envoient en même temps : le second message part en
  file au lieu d'un 409. Une tâche planifiée garde le refus.
- Un raisonnement qui reprend après du texte de réponse ouvre une nouvelle
  bulle sous la réponse au lieu de remplir l'ancienne, repliée au-dessus.
- Bascule de preset par identifiant : le numéro seul visait un autre
  preset quand la liste avait bougé sur un autre appareil. Le numéro reste
  accepté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:21:18 +02:00
MichaelandClaude Opus 5.5 7f8c5d360f Agent : reprise après coupure, appels parallèles séparés, outils gardés hors 500
Repris d'AJEAN 0.16.4 et 0.17.4.

- Flux coupé en cours de réponse vers une API externe (Wi-Fi, VPN, proxy
  qui décroche) : le tour reprend au lieu d'être abandonné, jusqu'à 5 fois
  avec attente croissante. Rien d'affiché : la requête est rejouée (le
  raisonnement montré est retiré). Du texte affiché : il est rendu au
  modèle avec « continue là où tu t'es arrêté », et la suite s'ajoute à
  l'écran. Un outil à moitié écrit est clos puis réémis. Jamais pour le
  llama-server local : coupé, il a planté. Une coupure après le dernier
  chunk (finish_reason reçu) garde la réponse telle quelle.
- Appels d'outils parallèles : chaque morceau est rangé selon son champ
  index, plus selon sa position dans le chunk. Un serveur qui envoie un
  appel par chunk les fusionnait (noms écrasés, JSON « {…}{…} »).
- Outils coupés et « réponds maintenant » seulement sur un 500 (appel mal
  formé). Un 401, 429 ou 502 d'une API externe coupait les outils et
  rejouait le tour, masquant la vraie cause.
- Débit de décodage mesuré côté Loki quand le serveur n'envoie pas de
  timings ; le débit de lecture, inconnu, n'est plus affiché à 0.
- Windows : la connexion refusée (WSA 10061) est reconnue comme telle par
  la reprise réseau (TestConnexionRefuseeEstRejouee échouait sous Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:18:35 +02:00
MichaelandClaude Opus 5.5 2db0ecd431 Presets externes : vision, compactage, bench et badge pour une API distante
La reprise faite en parallèle sur l'autre branche (b667fd4, jamais poussée)
couvrait des trous de celle-ci. Ses ajouts, posés sur la version en place :

- chat_template_kwargs, propre à llama.cpp, ne part plus vers une API
  distante — ni au chat ni au résumé de compactage. Une API stricte
  (OpenAI) répond 400 à un argument inconnu : toute compaction échouait.
- Vision : case « le modèle accepte les images » (EXTERNAL_VISION) dans la
  fenêtre API externe. Elle remplace, en externe, MMPROJ et la sonde
  /props : un modèle distant multimodal ne pouvait jamais recevoir d'image.
- Le benchmark refuse de tourner sur un preset externe : healthCheck le dit
  prêt sans moteur, et la mesure tapait un port arrêté ou un moteur resté
  en vie, attribuée à tort à ce preset.
- Le badge de modèle des réponses porte le nom du modèle distant.
- Éditer le preset en service l'applique aussitôt (SavePresetApplying).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:13:56 +02:00
MichaelandClaude Opus 5.5 48b8193dca Chat : écrire pendant que l'IA répond, et deux fils ne fusionnent plus
Repris d'AJEAN 0.14.0, adapté.

AJOUT EN COURS DE RÉPONSE
Il fallait arrêter la génération pour glisser une précision (le serveur
répondait 409). Un message envoyé pendant une réponse part désormais EN
FILE : runChat l'injecte à la prochaine frontière d'étape (après un appel
d'outil) — le modèle en tient compte dans la SUITE de sa réponse — ou, si
le tour se termine avant, il devient le tour suivant, dans l'ordre. Le
bouton envoyer réapparaît à côté de stop dès qu'il y a du texte ; le
message s'affiche « en attente » au-dessus de la carte jusqu'à ce que le
flux le confirme. Stop abandonne la file (queue_dropped, le client retire
ses pastilles).

Écarts avec l'amont :
- Les messages en attente sont posés AU-DESSUS de la carte, pas dans le
  fil qui s'écrit encore (ils s'y seraient intercalés entre deux bulles).
- Dédoublonnage par identifiant d'envoi (cid) : l'UI réessaie un envoi dont
  la réponse s'est perdue sur le tunnel. Le 409 « déjà en cours » faisait
  office de garde ; sans lui, le réessai mettait le message deux fois en
  file.
- Une tâche planifiée qui occupe le modèle garde le refus 409 (sa fin ne
  dépile rien).
- Pas d'injection après un stop : la boucle repassait en tête une fois
  l'outil interrompu et journalisait le message en file (vu en test).
- runChat prend l'injecteur en paramètre variadique : les appels hors chat
  (tâches, vérification du mode Code, tests) ne changent pas.

DEUX FILS FUSIONNÉS
Un appareil déconnecté pendant qu'un autre changeait de discussion se
réabonnait avec un `from` hérité de l'ancienne : les Seq n'étant pas
globaux, la fin de la nouvelle discussion se greffait sur le début de
l'ancienne restée à l'écran. caught_up et reset portent maintenant l'id de
la discussion ; le client le renvoie (conv_id) et, s'il ne correspond plus,
le serveur ordonne un reset avant de rejouer. La garde « from au-delà du
dernier Seq » émet elle aussi ce reset (elle rejouait par-dessus l'écran
sans le vider).

Vérifié dans le navigateur avec un faux moteur : précision injectée après
l'outil dans le même tour, message en file devenu tour suivant, stop qui
abandonne la file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:11:40 +02:00
MichaelandClaude Opus 5.5 58c2da1302 Chiffrement : les résultats d'outils et les images du fil suivent la mémoire
Les deux reprises d'AJEAN arrivées en parallèle du chiffrement écrivaient
en clair à côté de discussions chiffrées.

- Résultats complets des outils (« voir plus ») : bucket toolres ajouté à
  encryptedBuckets, écrits et relus par putStoreBytes / getStoreBytesErr.
  Mémoire verrouillée : rien n'est écrit, le flux porte le résultat entier.
  Le nettoyage des orphelins ne tourne plus quand la mémoire est
  verrouillée : l'index des discussions revenait vide et TOUT passait pour
  orphelin.
- Images du fil (chatimg/) : même enveloppe que les pages mémoire ;
  verrouillée, l'image reste en base64 dans le message. Elles n'étaient
  jamais effacées : comme un fil rechargé perd ses images (stripImageParts),
  celles de plus de 24 h partent au démarrage et à chaque bascule de
  discussion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:10:56 +02:00
MichaelandClaude Opus 5.5 ae0e181711 Outils : aperçu dans le flux, « voir plus » à la demande, coupes annoncées
Repris d'AJEAN 0.15.1 → 0.15.7 (tool_results.go adapté : pas de
chiffrement chez nous).

VOIR PLUS
Chaque résultat d'outil partait en entier dans le flux (jusqu'à 12 000
caractères) et dans le journal rejoué à chaque ouverture : un long fil
d'agent en transportait des centaines. Le flux ne porte plus qu'un aperçu
de 1 600 caractères, la taille réelle (le « ~N tok » de la bulle reste
juste) et un id. Le résultat complet est rangé dans le bucket `toolres`,
sous « <discussion>.<aléa> » — un id propre, pas le tool_call_id que
certains parseurs recyclent. « voir plus » le charge au clic via
/api/chat/tool-result et lève le plafond de hauteur du bloc ; il se range
avec « copier » dans une barre au coin du résultat. Les résultats partent
avec leur discussion (convDelete) ; les orphelins sont nettoyés toutes les
200 écritures.

COUPES ANNONCÉES
- Terminal : une sortie de plus de 8 000 caractères était coupée en
  silence ; le modèle croyait tout voir. Elle commence désormais par
  « [sortie tronquée : N caractères au total, seuls les 8000 derniers sont
  gardés] ».
- MCP : l'amont transmet désormais la réponse en entier. On s'en approche
  sans renoncer au garde-fou (65k de contexte) : le plafond suit la
  fenêtre (CTX caractères, ≈ un quart de la fenêtre, jamais sous les
  12 000 d'avant), et la coupe dit la taille réelle.

Vérifié dans le navigateur avec un faux moteur qui appelle bash : aperçu
de 1 600 caractères, « voir plus » charge les 8 099, mention de coupe en
tête.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:08:46 +02:00
Michael 6aafeeea98 Revert "Résultats d'outils : aperçu + « voir plus », vrai compteur, MCP complet"
This reverts commit 91d1796615.
2026-10-02 23:08:31 +02:00
MichaelandClaude Opus 5.5 f54c304019 Chat : 20 derniers échanges au chargement, et le toucher revient sur iOS
Repris d'AJEAN 0.15.7.

HISTORIQUE PAGINÉ
Rouvrir une longue conversation d'agent rejouait TOUT le journal : des Mo
à travers le tunnel, et des milliers de bulles construites avant
d'afficher la moindre chose. Le client annonce désormais `tail` : au
chargement (et à l'ouverture d'une autre discussion), le serveur ne rejoue
que les 20 derniers échanges, précédés de {history_more: N}. Un bouton en
tête du fil redemande le rejeu complet en gardant la position de lecture.
Un client qui n'envoie pas `tail` (app relais…) reçoit tout, comme avant.

Écart avec l'amont : les états que le client garde et que la partie
masquée avait posés — mode Chat/Code, critères du mode Code — sont réémis
à la coupe (historyHead), ainsi que ctx_used à l'ouverture d'une
discussion. Sans ça, une discussion en mode Code rouverte s'affichait en
mode Chat, et la jauge de contexte à zéro.

iOS : LES TAPS PERDUS PENDANT UNE RÉPONSE
iOS annule un appui si la position de défilement change pendant qu'il a
lieu. Le fil se recalant en bas à chaque token, presque chaque tap tombait
dessus — y compris sur stop. Le suivi est suspendu pendant un contact (et
350 ms après), scrollTop n'est plus réécrit quand on est déjà en bas, et
stop agit dès l'appui sur écran tactile (anti-doublon pour le clic qui
suit).

Vérifié dans le navigateur sur 23 échanges : 20 affichés + « afficher les
3 échanges précédents », puis rejeu complet à position de lecture égale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:07:56 +02:00
MichaelandClaude Opus 5.5 51c791268d Fil long : retour au replay complet avant la reprise du fenêtrage par échanges
Annule c32fd40. Le replay borné aux 4000 derniers événements coupait au
milieu d'un tour, perdait l'état du mode Code (critères, jauge de contexte)
porté par la partie masquée et s'imposait à tous les clients. Le fenêtrage
par échanges repris d'AJEAN (commit suivant) coupe aux frontières d'échange
et renvoie cet état en tête.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:07:46 +02:00
MichaelandClaude Opus 5.5 04137411fa Discussions : la liste se dessine par pages de 40
Repris d'AJEAN 0.15.6, pour la partie qui nous concerne. Côté serveur, la
liste était déjà légère (l'index ne porte que des métadonnées, aucune
conversation n'est relue) ; c'est le DOM qui coûtait : des centaines de
lignes construites d'un coup à chaque rafraîchissement de la liste.

On en dessine 40, puis la suite quand la ligne « afficher N de plus »
arrive à l'écran (IntersectionObserver, ou clic). La discussion active
reste toujours visible, même au-delà. La recherche filtre toujours la
liste ENTIÈRE, qui reste en mémoire ; changer la requête repart de la
première page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 f7a4ee71e7 Chat : les images ne sont plus réécrites en base64 à chaque tour
Repris d'AJEAN 0.15.7 (chat_images.go et son test), adapté.

Une image jointe (ou une capture de web_screenshot) vivait en base64 dans
les messages de la conversation, et persist() réécrit la conversation
ENTIÈRE à chaque fin de tour : une photo de téléphone, c'étaient des Mo
remis sur disque à chaque réponse. Elle est désormais rangée une fois sous
$LOKI_HOME/chatimg/<empreinte>.<ext> (hors du dossier de travail, que l'IA
peut vider) ; l'historique n'en garde que « loki-img:<nom> ». Juste avant
l'envoi, expandImageRefs remet les octets exacts (cache mémoire borné à
64 Mo) : pour le modèle, rien ne change.

DEUX ÉCARTS AVEC L'AMONT
- Copie à l'écriture : l'amont remplace l'URL en place. Chez nous, persist
  peut tourner pendant qu'un runChat sérialise ces mêmes maps (compactage
  en vol, bascule de projet) — « concurrent map read and map write », fatal.
  Les parties modifiées sont donc recopiées.
- Le rechargement reste éphémère : stripImageParts retire toujours les
  images d'une conversation rouverte (garde-fou contre un modèle sans vision
  qui lirait le base64 comme du texte). L'amont, lui, les garde ; on
  pourra le suivre plus tard si l'on veut.

Pas de migration des archives : une conversation rouverte est allégée par
stripImageParts, comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 1db987844e Chargement du modèle : --mlock seul ne coupe plus le mmap
UN VRAI BUG D'ABORD
normalizeLoadFlags traduisait --mlock seul en « --load-mode mlock ». Or,
pour llama.cpp (common/arg.cpp), « mlock » veut dire PAS de mmap + résident :
le modèle entier montait en RAM au lieu d'être mappé. L'ancien --mlock, lui,
gardait le mmap — son équivalent est « mmap+mlock ». Sur un modèle plus gros
que la RAM (Qwen3.8-Flash-Next, 82 Go pour 64 Go), un preset qui avait
coché « Garder en RAM » tournait donc à l'OOM dès qu'un moteur récent
prenait le relais. Correspondance alignée sur AJEAN 0.16.0 :
  --mlock seul → mmap+mlock · --no-mmap → none · les deux → mlock.

DANS LES DEUX SENS
Un moteur ancien (ou un fork) ne connaît pas --load-mode et sort en erreur
si on le lui passe : downgradeLoadMode le retraduit en anciens drapeaux
(dio, inconnu de ces moteurs, est abandonné avec un avertissement).

UN SÉLECTEUR À LA PLACE DE DEUX INTERRUPTEURS
« Garder en RAM » et « Charger tout en mémoire » ne disaient pas leur
combinaison. L'éditeur de preset propose les six modes de llama.cpp (auto,
mmap, none, mlock, mmap+mlock, dio), avec ce que fait chacun en sous-titre,
et écrit la forme moderne. Un preset aux anciens drapeaux s'affiche sur le
bon mode sans modification.

Au passage, q5_1 porte la mention « lent sur CUDA » dans la liste du cache
KV (voir le commit du port occupé : non accéléré en Flash-Attention).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 f75f3cf27d Web : réponses compressées en gzip
Repris d'AJEAN 0.15.7 (web_gzip.go et son test, tels quels).

L'UI (~590 Ko de HTML/JS/CSS en un seul fichier) et les gros JSON
(historique relu, liste des sessions, journal) partaient en clair. gzip
les divise par 4 à 8 — ça se sent sur un téléphone en Wi-Fi ou à travers
le tunnel.

La décision se prend au premier octet, sur le Content-Type posé par le
handler : les flux SSE (chat, complétions /v1 en streaming) et les
binaires passent tels quels — un flux compressé retiendrait ses
événements dans le tampon de gzip. Flush reste transmis pour les réponses
progressives, et Unwrap laisse le WebSocket des postes distants atteindre
le Hijacker d'origine.

Seul l'écouteur local est enveloppé : le tunnel sert le mux nu, comme
avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 38802d9e21 Presets : une bascule faite sur un appareil se voit sur les autres
Repris d'AJEAN 0.13.6. Changer de preset sur le téléphone laissait l'onglet
du PC afficher l'ancien, sélection de la liste comprise, jusqu'à un
rechargement à la main. /api/status (sondé toutes les 5 s) expose l'id du
preset actif ; loadStatus rappelle loadPresets quand il change sans qu'on
y ait touché ici.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 2c36fa6d2c Mémoire : un mode par projet, et un quatrième — « recherche »
Repris d'AJEAN 0.13.12.

PAR PROJET
Le mode mémoire était un réglage global de config.env. Il vit désormais
sur le projet (champ mem_mode) : un chantier de code peut couper la
mémoire pendant qu'un projet perso la garde proactive. Un projet sans
réglage propre — tous ceux d'avant — retombe sur l'ancien MEM_MODE global :
rien ne change tant qu'on n'y touche pas. L'API /api/memory et `loki
memory` agissent sur le projet actif ; l'UI le dit, et se recharge déjà au
changement de projet.

AUTO, EN DEUX SAVEURS
- « auto, injectée » (always, le défaut) : l'index des pages est en tête
  de conversation. La consigne dit maintenant d'y lire directement la
  bonne page ; mem_search ne sert plus qu'à chercher par contenu. Avant,
  on injectait l'index ET on exigeait une recherche préalable — un appel
  d'outil par tour pour retrouver ce que le modèle avait sous les yeux.
- « auto, recherche » (nouveau) : rien d'injecté, l'IA cherche avant
  chaque tâche. Contexte plus léger, démarrage plus rapide.
memProactive regroupe les deux là où seul « proactif » compte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:08 +02:00
MichaelandClaude Opus 5.5 4a1054c1b7 Mémoire : après un compactage, le modèle sait quelles pages il avait lues
Repris d'AJEAN 0.14.0 (chat_mem_pinned.go et ses tests, tels quels).

Le compactage résume le torse, résultats de mem_read compris. Une page qui
portait TOUTES les règles d'une tâche se retrouvait réduite à trois mots
dans le résumé, et le modèle, après compactage, les oubliait. Le rappel ne
recopie rien : il LISTE les pages lues (bornées aux 24 plus récentes) et
invite à les relire si elles concernent la tâche. Il s'accumule d'un
compactage à l'autre — l'ancien rappel est relu comme source, puisque le
mem_read d'origine a disparu.

Posé seulement en mode agent (sans lui, pas de mem_read pour y donner
suite). Comme le contexte projet, le rappel est sorti du bloc système
commun vers le premier message utilisateur (isProjectSystem) : il est
propre à la conversation et casserait sinon le cache du système partagé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 626227f2f2 Projets : changer de projet ne recalcule plus tout le prompt
Repris d'AJEAN 0.15.8, adapté : chez nous le contexte projet n'est jamais
persisté, il est injecté à chaque tour sous forme de messages système
préfixés — ce qui rend le déplacement trivial et sans migration.

LE PROBLÈME
Sur un modèle hybride (Qwen3.5 et suivants, couches récurrentes), llama.cpp
ne peut reprendre un prompt déjà calculé qu'à un point de sauvegarde, et il
n'en pose qu'au DÉBUT d'un message utilisateur. La description du projet,
l'index mémoire et l'index des trackers étaient fusionnés dans le bloc
système : deux projets divergeaient AVANT le premier point de reprise, et le
premier message après un changement de projet recalculait tout (l'amont
mesure ~8 s pour 5 000 tokens sur un 27B).

LE CORRECTIF
normalizeSystemMessages sort ces messages (isProjectSystem, par leurs
préfixes) du bloc système et les place, dans un bloc <project_context>, en
tête du PREMIER message utilisateur de la séquence envoyée. Le système
commun reste identique d'un projet à l'autre, donc en cache. L'historique
persisté et l'affichage ne changent pas (copie, entrée non mutée — testé).

InjectSkills ne fusionne plus son préambule DANS un message projet placé en
tête (cas d'un preset sans prompt système) : il y aurait perdu son préfixe
et serait resté dans le bloc commun.

Le gain ne vaut qu'entre projets de même mode mémoire : la consigne mémoire
fait partie du système commun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 4602614be0 Chat : mode agent coupé = modèle brut, vraiment
Repris d'AJEAN 0.13.12 / 0.13.13. Couper le mode agent retirait bien les
outils, la mémoire et le préambule Loki, mais laissait passer deux choses :

- Le prompt système du PRESET et le contexte du projet (description, index
  mémoire, trackers). Ils décrivent des outils et une mémoire que ce mode
  n'a pas : le modèle, persuadé de pouvoir agir, écrivait des <tool_call>
  en clair dans sa réponse. Agent off, generate n'injecte plus rien — le
  modèle reçoit les messages nus, comme un llama-server direct.
- Un tool_call que le moteur parvient à parser alors qu'aucun outil n'a été
  annoncé (agent off, ou relance sans outils après un 500). Il était exécuté,
  répondait « outil inconnu », et la boucle repartait. Il est désormais
  ignoré : le modèle répond en texte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 f117a466af Presets : fini le benchmark fantôme
Repris d'AJEAN 0.16.3. Les benchmarks sont rangés par id de preset. Un
preset dont on changeait le modèle — ou supprimé puis recréé sous le même
nom — affichait les mesures d'un AUTRE modèle, qui n'avaient rien à voir.

- benchMatchesPreset : un bench n'est affiché que si le modèle mesuré est
  celui du preset (le nom de fichier est enregistré avec la mesure depuis
  toujours, il ne servait simplement à rien).
- DeletePreset oublie le bench, comme il oubliait déjà le prompt système.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 97378da7af Agent : trois filets de secours qui faisaient plus de mal que de bien
Repris d'AJEAN 0.15.5.

COMPACTAGE DE SECOURS
Sur n'importe quel refus du moteur — appel d'outil mal formé, modèle en
cours de chargement, erreur de template — runChat résumait ~75 % de la
conversation avant de rejouer, même quand elle tenait en trois messages.
contextOverflow ne laisse passer que les vrais débordements : libellés de
llama.cpp (« exceeds the available context size ») et des API
OpenAI-compatibles (context_length_exceeded), ou, à défaut de libellé, une
conversation déjà à 90 % de la fenêtre.

ARGUMENTS D'OUTIL ILLISIBLES
Un JSON tronqué était neutralisé en {} (indispensable : le template les
re-parse à chaque requête) PUIS exécuté tel quel, d'où des erreurs
trompeuses (« fichier manquant », « commande vide ») qui envoyaient le
modèle sur une fausse piste. L'appel n'est plus exécuté ; le modèle reçoit
une erreur qui dit exactement quoi renvoyer. Des arguments VIDES restent
valides (outil sans paramètre).

RELANCE SANS OUTILS
La consigne imposait le français. Elle demande désormais la langue de
l'utilisateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:35 +02:00
MichaelandClaude Opus 5.5 e12f07426f Diff : le compteur de lignes dit enfin la vérité
Repris d'AJEAN 0.16.0 (chat_diff.go et son test, repris tels quels : le
fichier n'avait pas bougé chez nous depuis la 0.13.5).

- Un fichier de 500 lignes écrit par l'agent affichait « +500 » pendant la
  frappe puis retombait à « +120 » : le diff envoyé à l'UI est plafonné à 120
  lignes, et l'UI recomptait sur la version tronquée. lineDiff et addedDiff
  rendent désormais les VRAIS totaux, calculés avant la coupe, et ils
  voyagent dans le journal (added/removed) — donc survivent au refresh.
  Repli sur l'ancien décompte pour les conversations d'avant.
- Une retouche d'une ligne dans un bloc de plus de 400 lignes s'affichait en
  remplacement complet (le LCS n'y était pas tenté). Les lignes communes en
  tête et en queue sont écartées d'abord : on voit le vrai changement, avec
  trois lignes de contexte, et il n'est plus repoussé hors de la fenêtre.
- Un contenu terminé par un saut de ligne ne compte plus une ligne de trop,
  côté serveur comme dans la bulle en cours de frappe (bodyLineCount).

Le vérificateur du mode Code (code_verify.go) journalise les mêmes champs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:30 +02:00
MichaelandClaude Opus 5.5 20375d0e4c Moteur : port déjà occupé refusé, cache KV lent signalé
Deux reprises d'AJEAN 0.15.9, côté `loki serve`.

PORT OCCUPÉ
Un llama-server orphelin (un stop qui n'a pas tué, un relancement trop
rapide) peut encore tenir le port quand le suivant démarre. Deux moteurs
sur le même port se partagent alors les requêtes au hasard : VRAM saturée,
réponses du mauvais modèle. waitPortFree tente une connexion TCP (fiable
même en SO_REUSEADDR, là où un Listen de test réussirait), laisse 5 s à un
moteur qu'on vient d'arrêter pour libérer, puis refuse avec un message qui
dit quoi faire. argValue lit la DERNIÈRE occurrence de --port/--host, comme
llama-server, puisque EXTRA_ARGS peut les surcharger.

CACHE KV LENT
llama.cpp CUDA, compilé avec ses options par défaut (FA_ALL_QUANTS=OFF),
n'accélère en Flash-Attention que f16, bf16, q8_0 et q4_0 — et seulement à
l'identique pour K et V. C'est le cas du moteur de l'image server-cuda et de
ceux installés par OCI. q5_1 ou un couple mixte q8_0/q4_0 marchent mais sont
reconvertis en f16 à chaque pas. L'amont ne prévient que pour son binaire
précompilé ; chez nous tous les moteurs sont concernés, donc l'avertissement
part toujours dans loki-engine.log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:10 +02:00
MichaelandClaude Opus 5.5 b893d1a81f API : un site tiers ne peut plus piloter Loki en douce
Repris d'AJEAN 0.15.5. Sans clé de pilotage (le défaut), l'API /api était
ouverte à tout ce qui savait joindre le port — y compris une page web
quelconque ouverte dans un navigateur du réseau local. Un POST « simple »
(text/plain) ne déclenche aucune pré-vérification CORS : la page pouvait
changer des réglages et, mode agent actif, faire exécuter des commandes.

requireWebAuth passe désormais par crossSiteReject, AVANT le test de clé :
- Sec-Fetch-Site: cross-site → 403 ;
- Origin présent et différent de l'hôte appelé (ou « null ») → 403. curl,
  les scripts et les apps n'envoient pas d'Origin : non concernés ;
- sans clé seulement, l'hôte appelé doit être local (IP, localhost, nom sans
  point, .local/.lan/.home…, nom du conteneur) : c'est ce qui coupe le DNS
  rebinding, où un domaine malveillant se fait résoudre en IP locale.

DEUX ÉCARTS AVEC L'AMONT, DUS AU CONTENEUR
- LOKI_TRUSTED_HOSTS : derrière un reverse proxy, le nom public n'est ni
  local ni celui du conteneur. Plutôt qu'imposer une clé, on peut lister ce
  nom. Documenté dans le README.
- Le trafic du tunnel (marqué par withLocalAuth, authentifié par le relais)
  est dispensé du contrôle d'hôte : son Host est celui du relais.

À SAVOIR : un accès existant par nom de domaine SANS clé ni
LOKI_TRUSTED_HOSTS est désormais refusé (403, message explicite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:05 +02:00
Claude 513aca1c7b Version 0.13.0
Le rattrapage de l'amont (navigateur piloté, chiffrement de la mémoire,
notifications, tâches script, sous-agents, anglais) était parti sur main
sous le numéro 0.12.3 : huit fonctionnalités sous un numéro de correctif.

Bump des trois porteurs de version — la constante Go, versioninfo.json et
les .syso Windows régénérés (go generate) — et notes de release réécrites
pour 0.13.0. Sans ça, `loki update` et le bandeau de mise à jour comparaient
une version qui ne bougeait pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-23 07:52:59 +00:00
89 changed files with 5407 additions and 711 deletions

No files matched your search

+9 -4
View File
@@ -27,10 +27,15 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
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. S'y
ajoutent depuis deux idées reprises d'OpenFox et réimplémentées ici : les
**sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans son
propre contexte) et le **fenêtrage du fil** au chargement d'une longue
discussion.
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
son propre contexte), la **vérification déclenchée quand le builder a
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
qui garde l'état du mode Code.
## Reprises depuis l'amont après le fork
+9 -5
View File
@@ -124,6 +124,15 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
toute requête venue d'un autre site : une page web ouverte dans un navigateur
du réseau ne peut plus piloter Loki en douce (ni par DNS rebinding). Derrière
un reverse proxy (`loki.mondomaine.fr`), définir une clé
(`docker exec -it loki loki set-web-key`) ou lister le nom dans
`LOKI_TRUSTED_HOSTS`.
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `test`…).
@@ -326,11 +335,6 @@ Ajoutées par ce fork :
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
raisonnement est coupé pour ce modèle.
- **Ouverture rapide des longues discussions** : le serveur ne rejoue que la
**fin** du journal (les 4000 derniers événements). Un fil de plusieurs
centaines de tours s'affichait en quelques secondes et laissait autant de
bulles dans le DOM à traîner ensuite. Rien n'est effacé : un bandeau en tête
dit combien d'événements sont masqués et **charge le début** d'un clic.
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
+74 -34
View File
@@ -1,50 +1,90 @@
# Loki 0.12.0
# Loki 0.14.0
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.
Loki rattrape AJEAN jusqu'à la 0.17.6 et reprend une série d'idées d'OpenFox
pour le mode Code. Le gros du travail est invisible : des discussions qui ne se
bloquent plus sur un contexte plein, des réponses qui survivent à une coupure
réseau, un agent de code qui gaspille moins de tours.
## Le sélecteur de l'en-tête charge vraiment un modèle
## Le contexte ne bloque plus la discussion
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.
Sur une fenêtre de 65k, un tour qui lisait plusieurs gros fichiers d'un coup
passait de 60 % à plus de 130 % sans jamais compacter, et le moteur répondait
par un 400 qui figeait la discussion.
## Dictée vocale
- Le compactage en cours de tour compte aussi les résultats d'outils que le
moteur n'a pas encore vus.
- Tout résultat d'outil est borné à 30 000 caractères **pour le modèle** ;
l'interface garde le résultat complet (« voir plus »).
- En dernier recours, les gros résultats sont tronqués puis les plus vieux
échanges retirés, au lieu de laisser remonter l'erreur.
- Le compactage ne fait plus répondre deux fois à une vieille question, et le
texte écrit avant un appel d'outil n'est plus rangé en double.
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.
## Presets : API compatible OpenAI
## Cartes raisonnement/outils : taille fixe, défilement qui suit
Les presets d'API externe gagnent ce qui leur manquait :
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.
- une case **« le modèle accepte les images »** pour un modèle distant
multimodal ;
- plus de `chat_template_kwargs` (propre à llama.cpp) envoyé à l'API : une API
stricte répondait 400 et **chaque compactage échouait** ;
- **reprise automatique** quand le flux se coupe en pleine réponse (Wi-Fi, VPN,
proxy) : jusqu'à 5 fois, le modèle reprend là où il s'était arrêté ;
- un 401, 429 ou 502 n'est plus pris pour un appel d'outil mal formé ;
- le débit est mesuré même quand l'API ne le donne pas ; le benchmark refuse de
mesurer un moteur local qui n'est pas celui du preset ; le badge des réponses
porte le nom du modèle distant.
## Mesures
## Mode Code
- 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).
- **La vérification attend que le builder ait fini.** Il marque chaque critère
`completed` une fois fait ; tant qu'il en reste d'ouverts, il est relancé au
lieu de payer une passe de vérification sur un travail inachevé. Plus aucune
vérification ne part quand il vient de poser une question.
- **Écriture refusée dès son chemin** : un `write` sur un fichier jamais lu
était refusé après tout le fichier généré. Le chemin passe maintenant en
premier et le refus coupe la génération aussitôt.
- **Appel d'outil écrit en texte** (`<tool_call>`, format XML de Qwen3-Coder) :
repéré pendant le flux, coupé, et la relance cite l'extrait fautif.
- `read_file`, `str_replace`, `old_string`… appris d'autres agents sont
traduits vers les vrais outils.
- Les consignes du dépôt (**AGENTS.md**, CLAUDE.md) font partie du contexte.
- Terminal : `cmd | tail -N` rend le **vrai** code de sortie, la sortie déjà
produite est gardée au délai dépassé, les couleurs ANSI sont retirées.
- `git_status` / `git_diff` trouvent le dépôt cloné dans un sous-dossier.
- Le compactage garde les fichiers touchés, les erreurs résolues et l'état des
critères.
## Interface
## Tâches et chat
- 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.
- **« Rappelle-moi dans 20 minutes »** : `task_create` accepte `in_minutes` ou
`at`, à l'heure du navigateur (le conteneur tourne en UTC).
- **Écrire pendant que l'IA répond** : le message part en file et le modèle en
tient compte dans la suite de sa réponse ; deux appareils qui envoient en
même temps ne se refusent plus.
- Longues discussions : au chargement, les 20 derniers échanges, le début d'un
clic ; la liste des discussions se dessine par pages et montre la nouvelle
dès le premier message.
- Mémoire : un mode par projet, et un quatrième, « recherche ».
- Appels d'outils parallèles séparés selon leur index ; bascule de preset par
identifiant ; un raisonnement qui reprend après la réponse a sa propre bulle.
## Rappel 0.11.0
## Sécurité et données
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.
- **Un site tiers ne peut plus piloter Loki** depuis le navigateur (Origin /
Sec-Fetch-Site, DNS rebinding). ⚠️ Derrière un reverse proxy par nom de
domaine, définis une clé de pilotage ou `LOKI_TRUSTED_HOSTS` **avant** la mise
à jour, et vérifie que le proxy transmet l'en-tête `Host` d'origine.
- **Chiffrement de la mémoire** : l'activer rendait illisibles la discussion
active et le mode Code. Corrigé ; une base déjà touchée est réparée au
déverrouillage. Les résultats d'outils (« voir plus ») et les images du fil
sont désormais chiffrés eux aussi, et les images effacées après 24 h.
## Moteur
- `--mlock` seul ne coupe plus le mmap (OOM au chargement).
- Port déjà occupé refusé avec un message clair ; cache KV lent signalé.
- Réponses web compressées en gzip.
## Mise à jour
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 12,
"Patch": 3,
"Minor": 14,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 12,
"Patch": 3,
"Minor": 14,
"Patch": 0,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.12.3",
"ProductVersion": "0.14.0",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
@@ -34,4 +34,4 @@
"CharsetID": "04B0"
}
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ Method — in this order:
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.
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
- 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.
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
+19 -6
View File
@@ -24,10 +24,11 @@ import (
)
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
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
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
)
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
@@ -46,6 +47,14 @@ func isExternalConfig(cfg map[string]string) bool {
// externalActive : le preset actif est-il un endpoint externe ?
func externalActive() bool { return isExternalConfig(ReadConfig()) }
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
// preset dit si le modèle accepte les images.
func externalVisionActive() bool {
cfg := ReadConfig()
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
}
// completionsURL normalise l'URL saisie en une URL de complétions complète.
// Accepte les trois formes qu'on colle en pratique :
//
@@ -102,8 +111,9 @@ func (e chatEndpoint) auth(set func(k, v string)) {
// 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 {
// compaction, exactement comme pour un preset local. vision = le modèle distant
// accepte les images (EXTERNAL_VISION=1).
func externalPresetContent(url, model, key, ctx string, vision bool) string {
m := map[string]string{
extKeyFlag: "1",
extKeyURL: strings.TrimSpace(url),
@@ -112,6 +122,9 @@ func externalPresetContent(url, model, key, ctx string) string {
if k := strings.TrimSpace(key); k != "" {
m[extKeyToken] = k
}
if vision {
m[extKeyVision] = "1"
}
if c := strings.TrimSpace(ctx); c != "" {
m["CTX"] = c
}
+53 -4
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -46,7 +47,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
@@ -71,7 +72,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
@@ -142,7 +143,7 @@ func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
if err != nil {
t.Fatal(err)
}
@@ -179,7 +180,7 @@ func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
// 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", ""))
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
if err != nil {
t.Fatal(err)
}
@@ -194,3 +195,51 @@ func TestEffacementExpliciteDeLaCle(t *testing.T) {
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
}
}
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
// échouait à chaque fois.
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["chat_template_kwargs"]; ok {
w.WriteHeader(400)
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compactage refusé par l'API distante : %v", err)
}
}
// Vision et benchmark d'un preset externe : la case du preset décide, et le
// bench (qui mesure le moteur local) refuse de tourner.
func TestPresetExterneVisionEtBench(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
t.Fatal(err)
}
if !visionEnabled() || !engineSeesImages() {
t.Error("preset externe déclaré multimodal : la vision devrait être active")
}
if _, err := runBench(10, 10); err == nil {
t.Error("benchmark lancé sur un preset externe")
}
if got := chatModelName(); got != "gpt-4o" {
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
}
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
t.Fatal(err)
}
if visionEnabled() {
t.Error("preset externe texte seul : la vision devrait être coupée")
}
}
+3
View File
@@ -432,6 +432,9 @@ func DeletePreset(id string) error {
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
// sous le même id.
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
// Même ménage pour son benchmark : un preset recréé sous cet id en
// hériterait, alors qu'il a été mesuré sur un autre modèle.
deletePresetBench(id)
return nil
}
+148 -13
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
@@ -140,22 +141,34 @@ func hasAnyFlag(args []string, flags ...string) bool {
return false
}
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
// --load-mode, sur les moteurs qui le connaissent :
// normalizeLoadFlags choisit, au lancement, la forme des drapeaux de chargement
// que CE binaire comprend. llama.cpp récent a remplacé --mlock / --no-mmap par
// --load-mode (et refuse les anciens) ; un moteur ancien ou un fork ne connaît
// que les anciens. L'éditeur écrit --load-mode, un preset d'avant peut porter
// les anciens : on traduit dans le sens qu'il faut (repris d'AJEAN 0.16.0).
//
// --mlock → --load-mode mlock (garder le modèle en RAM)
// --no-mmap → --load-mode none (tout charger, pas de mmap)
// --mmap → --load-mode mmap
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
//
// 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é.
// --mlock seul → mmap+mlock (l'ancien sens de --mlock : mmap conservé)
// --no-mmap seul → none
// --mlock + --no-mmap → mlock
// --mmap → mmap
//
// ⚠️ Jusqu'ici --mlock seul devenait « mlock », qui COUPE le mmap : le modèle
// entier montait en RAM au lieu d'être mappé — un OOM assuré sur un modèle plus
// gros que la mémoire. Un --load-mode déjà écrit gagne sur tout : on retire
// alors simplement les vieux drapeaux.
//
// Vers les anciens drapeaux (moteur qui ne connaît pas --load-mode) :
//
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
// auto, mmap, dio → rien (défaut de l'ancien moteur)
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
if !supportsLoadMode {
return args
return downgradeLoadMode(args)
}
explicit := hasAnyFlag(args, "--load-mode")
explicit := hasAnyFlag(args, "--load-mode", "-lm")
mlock, nommap, mmap := false, false, false
out := make([]string, 0, len(args)+2)
for _, a := range args {
@@ -175,14 +188,62 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
}
mode := "mmap"
switch {
case mlock:
case mlock && nommap:
mode = "mlock"
case mlock:
mode = "mmap+mlock"
case nommap:
mode = "none"
}
return append(out, "--load-mode", mode)
}
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
func downgradeLoadMode(args []string) []string {
mode, found := "", false
kept := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--load-mode" || a == "-lm" {
found = true
if i+1 < len(args) {
mode = strings.ToLower(args[i+1])
i++
}
continue
}
if v, ok := strings.CutPrefix(a, "--load-mode="); ok {
found, mode = true, strings.ToLower(v)
continue
}
kept = append(kept, a)
}
if !found {
return args
}
add := func(flag string) {
for _, a := range kept {
if a == flag {
return
}
}
kept = append(kept, flag)
}
switch mode {
case "none":
add("--no-mmap")
case "mlock":
add("--mlock")
add("--no-mmap")
case "mmap+mlock":
add("--mlock")
case "dio":
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut\n")
}
return kept
}
// cmdServe replaces the historic start.sh: read config.env, build the
// llama-server invocation, and exec it (replacing this process so systemd
// supervises llama-server directly).
@@ -370,10 +431,84 @@ func cmdServe(args []string) error {
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
// Port déjà pris (souvent un llama-server orphelin qu'un stop n'a pas pu
// tuer) : deux moteurs sur un même port se partagent les requêtes au hasard,
// VRAM saturée et réponses du mauvais modèle. On refuse en clair.
host, port := argValue(llmArgs, "--host"), argValue(llmArgs, "--port")
if err := waitPortFree(host, port, 5*time.Second); err != nil {
return err
}
warnSlowKV(ktv, vtv)
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), get("PORT", "8080"))
bin, filepath.Base(model), port)
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
// retient la dernière : EXTRA_ARGS peut surcharger --port).
func argValue(args []string, flag string) string {
v := ""
for i := 0; i+1 < len(args); i++ {
if args[i] == flag {
v = args[i+1]
}
}
return v
}
// waitPortFree vérifie que personne n'écoute déjà sur host:port. On tente une
// connexion TCP (fiable même si l'occupant a bindé en SO_REUSEADDR, où un
// Listen de test pourrait réussir). Un moteur qu'on vient d'arrêter peut tenir
// le port quelques instants : on réessaie jusqu'à wait avant de conclure.
// Repris d'AJEAN 0.15.9.
func waitPortFree(host, port string, wait time.Duration) error {
if port == "" {
return nil
}
h := strings.Trim(host, "[]")
if h == "" || h == "0.0.0.0" || h == "::" {
h = "127.0.0.1"
}
addr := net.JoinHostPort(h, port)
deadline := time.Now().Add(wait)
for {
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err != nil {
return nil
}
c.Close()
if time.Now().After(deadline) {
return fmt.Errorf("le port %s est déjà utilisé (sans doute un ancien llama-server encore actif) : "+
"« loki stop », docker restart loki, ou change PORT", port)
}
time.Sleep(500 * time.Millisecond)
}
}
// fastKV : combinaisons K/V que llama.cpp CUDA accélère en Flash-Attention
// avec ses options de compilation par défaut (GGML_CUDA_FA_ALL_QUANTS=OFF) —
// c'est le cas du moteur de l'image server-cuda comme de ceux tirés par OCI.
// Les autres (q5_*, q4_1, combinaisons mixtes) marchent mais sont reconverties
// en f16 à chaque pas : la génération ralentit nettement (AJEAN 0.15.9).
var fastKV = map[string]bool{"f16|f16": true, "q8_0|q8_0": true, "q4_0|q4_0": true, "bf16|bf16": true}
func slowKV(k, v string) bool {
if k == "" {
k = "f16"
}
if v == "" {
v = "f16"
}
return !fastKV[k+"|"+v]
}
func warnSlowKV(k, v string) {
if slowKV(k, v) {
fmt.Fprintf(os.Stderr, "[loki serve] avertissement : cache KV %s/%s non accéléré par llama.cpp CUDA "+
"(converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16.\n", k, v)
}
}
+44 -4
View File
@@ -1,8 +1,10 @@
package loki
import (
"net"
"reflect"
"testing"
"time"
)
func TestHasAnyFlag(t *testing.T) {
@@ -31,16 +33,25 @@ func TestNormalizeLoadFlags(t *testing.T) {
supports bool
want []string
}{
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
[]string{"--mlock", "--no-mmap"}},
{"mlock traduit", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mlock"}},
// L'ancien --mlock gardait le mmap : « mlock » de llama.cpp le coupe.
{"mlock seul → mmap+mlock", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mmap+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,
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
[]string{"--load-mode", "mlock"}},
{"moteur ancien : none retraduit", []string{"-fa", "--load-mode", "none"}, false,
[]string{"-fa", "--no-mmap"}},
{"moteur ancien : mlock retraduit", []string{"--load-mode", "mlock"}, false,
[]string{"--mlock", "--no-mmap"}},
{"moteur ancien : mmap+mlock retraduit", []string{"-lm", "mmap+mlock"}, false,
[]string{"--mlock"}},
{"moteur ancien : dio abandonné", []string{"--load-mode=dio", "-fa"}, false,
[]string{"-fa"}},
{"--load-mode explicite gagne, vieux drapeaux retirés",
[]string{"--mlock", "--load-mode", "dio"}, true,
[]string{"--load-mode", "dio"}},
@@ -91,3 +102,32 @@ func TestNGLArgs(t *testing.T) {
})
}
}
func TestArgValueEtPortLibre(t *testing.T) {
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
_, port, _ := net.SplitHostPort(ln.Addr().String())
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
t.Fatal("un port occupé doit être refusé")
}
ln.Close()
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
t.Fatalf("un port libre doit passer : %v", err)
}
}
func TestSlowKV(t *testing.T) {
for _, c := range []struct {
k, v string
lent bool
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
if slowKV(c.k, c.v) != c.lent {
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
}
}
}
+153 -3
View File
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
)
@@ -379,7 +380,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
}
}
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
var mid []Message
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
@@ -392,8 +393,18 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// 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).
sumMsg := compactSummaryUserMsg(summary, index)
// Mode Code : les critères ne vivent pas dans le fil (seuls les appels à
// l'outil criteria y passent, et ils viennent d'être résumés). On rend
// leur état au builder ici, dans un message de toute façon neuf — pas de
// cache invalidé en plus (repris d'OpenFox).
if caps.Code {
if list := critList(convEnsureActive()); len(list) > 0 {
sumMsg += "\n\nAcceptance criteria (current state):\n" + critRender(list)
}
}
mid = []Message{
{Role: "user", Content: compactSummaryUserMsg(summary, index)},
{Role: "user", Content: sumMsg},
{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."},
}
}
@@ -408,8 +419,23 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// On réinjecte donc textuellement la dernière vraie demande du torse, juste
// avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
// dans l'historique d'origine). Le torse reste entièrement compactable.
//
// Seulement quand c'est nécessaire (AJEAN 0.17.4), sinon deux bugs :
// - la queue contient déjà un message `user` : la dernière demande du torse a
// DÉJÀ reçu sa réponse, la réinjecter juste avant la nouvelle faisait
// répondre le modèle une seconde fois à une vieille question ;
// - repli sans résumé (mid = torse dégraissé) : ce message y est déjà, il se
// retrouvait en double, cette fois APRÈS ses propres résultats d'outils.
tailHasUser := false
for _, m := range msgs[tailStart:] {
if m.Role == "user" {
tailHasUser = true
break
}
}
summarized := err == nil && !summaryLooksEmpty(summary)
var pending []Message
for i := len(torso) - 1; i >= 0; i-- {
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
if torso[i].Role != "user" {
continue
}
@@ -571,6 +597,20 @@ type summarizeResp struct {
// Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle
// principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte).
func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
return summarizeTranscriptFor(ctx, transcript, false)
}
// codeSummaryRules : ce qu'un résumé de mode Code doit garder en plus — sans
// quoi le builder réécrit des fichiers déjà faits et retombe dans des erreurs
// déjà résolues (repris du COMPACTION_PROMPT d'OpenFox).
const codeSummaryRules = `
This is a CODING session. Also keep:
- Every file created or modified (path) and what changed in it
- Errors hit (build, tests, tools) and how each was resolved — or that it is still open
- The commands that build and test the project`
// summarizeTranscriptFor : code = résumé d'une session du mode Code.
func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) {
sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
@@ -583,6 +623,9 @@ Summarize densely and faithfully, keeping ONLY the essentials:
- 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. 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.`
if code {
sys += codeSummaryRules
}
// 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
@@ -605,6 +648,11 @@ Write the summary in the SAME language as the conversation.`
// passe ces kwargs au gabarit Jinja (--jinja).
"chat_template_kwargs": map[string]any{"enable_thinking": false},
}
if ep.External {
// Propre à llama.cpp : une API distante stricte (OpenAI) refuse un
// argument inconnu par un 400, et la compaction échouait à chaque fois.
delete(payload, "chat_template_kwargs")
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
@@ -649,3 +697,105 @@ Write the summary in the SAME language as the conversation.`
}
return c, nil
}
// contextOverflow : le refus du moteur (corps d'erreur msg) signale-t-il un
// débordement de la fenêtre de contexte ? Reconnaît les libellés de llama.cpp
// (« exceeds the available context size », exceed_context_size_error) et des API
// OpenAI-compatibles (context_length_exceeded, « maximum context length »). À
// défaut de libellé reconnu, on se fie à la taille estimée : une conversation
// déjà à 90 % de la fenêtre a toutes les chances d'être la cause.
func contextOverflow(msg string, msgs []Message) bool {
m := strings.ToLower(msg)
for _, k := range []string{"context size", "context_size", "context length", "context_length",
"context window", "n_ctx", "too many tokens", "prompt is too long", "exceeds the context"} {
if strings.Contains(m, k) {
return true
}
}
return estimateTokens(msgs) >= int(float64(ctxWindow())*0.9)
}
// ---- dernier recours : la requête ne rentre toujours pas -------------------
// Repris d'AJEAN 0.17.5.
var reqTokensRe = regexp.MustCompile(`request \((\d+) tokens\)`)
// overflowTokens extrait la taille réelle de la requête refusée par llama.cpp
// (« request (132291 tokens) exceeds… »), 0 si absente.
func overflowTokens(msg string) int {
if m := reqTokensRe.FindStringSubmatch(msg); m != nil {
n, _ := strconv.Atoi(m[1])
return n
}
return 0
}
const shrinkTruncMarker = "\n[…tronqué : contexte plein. Relance l'outil de façon plus ciblée si tu as besoin de la suite]"
// shrinkToFit est le filet quand le moteur a refusé le prompt ET que le
// compactage n'a rien pu faire (torse vide, ou réduction jugée trop faible :
// typiquement un tour qui a lu beaucoup de gros fichiers d'un coup, tout est
// dans la queue protégée). Avant, l'erreur 400 remontait telle quelle et la
// conversation restait bloquée. Ici on réduit pour de bon :
// 1. les gros résultats d'outils (du plus ancien au plus récent) sont tronqués ;
// 2. si ça ne suffit pas, on retire les plus vieux échanges, par tour entier
// (frontière `user`), en gardant les messages système de tête.
//
// La cible vise 60 % de la fenêtre, corrigée par l'écart entre estimation et
// taille réelle (actual, lue dans l'erreur) : l'estimation sous-compte (images…).
func shrinkToFit(msgs []Message, actual int) ([]Message, bool) {
est := estimateTokens(msgs)
if est <= 0 {
return msgs, false
}
ratio := 1.0
if actual > est {
ratio = float64(actual) / float64(est)
}
target := int(float64(ctxWindow()) * 0.6 / ratio)
out := append([]Message(nil), msgs...)
for i := range out {
if estimateTokens(out) <= target {
break
}
if out[i].Role != "tool" {
continue
}
if r := []rune(msgText(out[i])); len(r) > 2000 {
out[i].Content = string(r[:1500]) + shrinkTruncMarker
}
}
head := 0
for head < len(out) && out[head].Role == "system" {
head++
}
for estimateTokens(out) > target {
next := -1
for k := head + 1; k < len(out); k++ {
if out[k].Role == "user" {
next = k
break
}
}
if next < 0 {
break
}
out = append(out[:head:head], out[next:]...)
}
return out, estimateTokens(out) < est
}
// toolResultMax borne TOUT résultat d'outil dans la vue du modèle (≈ 8k
// tokens). Le terminal et le web ont déjà leur propre plafond, plus bas ;
// celui-ci rattrape les autres (lecture de fichier, pages mémoire, MCP…) : un
// seul résultat géant suffisait à faire déborder une fenêtre de 65k d'un coup,
// sans que le compactage puisse rien y faire. L'UI, elle, garde le résultat
// complet (« voir plus », tool_results.go).
const toolResultMax = 30000
func capToolResult(s string) string {
if r := []rune(s); len(r) > toolResultMax {
return string(r[:toolResultMax]) + "\n[…tronqué : résultat trop long. Cible une partie plus précise si tu as besoin de la suite]"
}
return s
}
@@ -0,0 +1,69 @@
package loki
import (
"strings"
"testing"
)
// Le compactage de secours ne doit se déclencher que sur un vrai débordement de
// contexte, pas sur n'importe quel refus du moteur.
func TestContextOverflow(t *testing.T) {
small := []Message{{Role: "user", Content: "salut"}}
yes := []string{
`{"error":{"code":400,"message":"request (70000 tokens) exceeds the available context size (65536 tokens), try increasing it","type":"exceed_context_size_error"}}`,
`{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}`,
}
no := []string{
`{"error":{"code":500,"message":"Failed to parse tool call arguments as JSON","type":"server_error"}}`,
`{"error":{"code":503,"message":"Loading model","type":"unavailable_error"}}`,
`Unable to generate parser for this template`,
}
for _, m := range yes {
if !contextOverflow(m, small) {
t.Errorf("débordement non reconnu : %s", m)
}
}
for _, m := range no {
if contextOverflow(m, small) {
t.Errorf("faux débordement (déclencherait un compactage) : %s", m)
}
}
}
// Le dernier recours ramène sous la fenêtre même quand tout est dans un seul
// tour (AJEAN 0.17.5).
func TestShrinkToFit(t *testing.T) {
testHome(t)
big := strings.Repeat("x", 200000)
msgs := []Message{
{Role: "system", Content: "ctx"},
{Role: "user", Content: "vieux"},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "lis tout"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "a"}}},
{Role: "tool", ToolCallID: "a", Content: big},
{Role: "tool", ToolCallID: "b", Content: big},
}
out, changed := shrinkToFit(msgs, 132291)
if !changed || out[0].Role != "system" {
t.Fatalf("pas réduit : %v", changed)
}
if est := estimateTokens(out); est > int(float64(ctxWindow())*0.6) {
t.Fatalf("encore trop gros : %d", est)
}
if overflowTokens(`{"message":"request (132291 tokens) exceeds"}`) != 132291 {
t.Fatal("taille réelle non lue")
}
}
// Un résultat d'outil géant est borné pour le modèle, avec une mention qui dit
// au modèle de cibler plus précisément.
func TestCapToolResult(t *testing.T) {
if got := capToolResult("court"); got != "court" {
t.Fatalf("résultat court modifié : %q", got)
}
got := capToolResult(strings.Repeat("é", toolResultMax+500))
if r := []rune(got); len(r) > toolResultMax+200 || !strings.Contains(got, "tronqué") {
t.Fatalf("résultat géant mal borné (%d runes)", len(r))
}
}
+75
View File
@@ -1,6 +1,10 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -177,3 +181,74 @@ func TestEstimateTokensGrows(t *testing.T) {
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
}
}
// Repli sans résumé (moteur absent) : la demande en cours est déjà dans le
// torse dégraissé, elle ne doit pas être réinjectée une seconde fois — elle
// apparaissait APRÈS ses propres résultats d'outils (AJEAN 0.17.4).
func TestCompactSansResumeNeDoublePasLaDemande(t *testing.T) {
testHome(t)
page := func(n int) Message {
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{
um("première question de la conversation"), am("ok"),
um("cherche les horaires du train pour Lyon"),
}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
out, _ := compactMessages(t.Context(), msgs, Caps{})
n := 0
for _, m := range out {
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
n++
}
}
if n != 1 {
t.Fatalf("demande en cours présente %d fois après compactage, attendu 1", n)
}
}
// Mode Code : le résumé demande les fichiers touchés et les erreurs, et l'état
// des critères est rendu au builder après compactage.
func TestCompactageModeCodeGardeLEtat(t *testing.T) {
withWorkspace(t)
toolCriteria(map[string]any{"action": "add", "texts": []any{"les tests passent"}}, false)
var sys string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
sys = msgText(body.Messages[0])
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- main.go modifié : la fonction parse corrigée, go build et go test passent"}}}})
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
page := func(n int) Message {
return tm("sortie de commande " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("bash"), page(i))
}
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true, Code: true})
if !changed {
t.Fatal("pas compacté")
}
if !strings.Contains(sys, "CODING session") {
t.Fatal("consigne de résumé du mode Code absente")
}
found := false
for _, m := range out {
if strings.Contains(msgText(m), "Acceptance criteria (current state)") && strings.Contains(msgText(m), "les tests passent") {
found = true
}
}
if !found {
t.Fatal("critères non rendus après compactage")
}
}
+345 -48
View File
@@ -3,6 +3,7 @@ package loki
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
@@ -68,6 +69,27 @@ type Conversation struct {
// à une génération fantôme dans la discussion ouverte.
runningTaskID string
runningTaskName string
// File d'attente des messages envoyés PENDANT une génération (AJEAN
// 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière
// d'étape (après un appel d'outil), ou — si le tour se termine avant —
// traités comme tours suivants, dans l'ordre. Protégée par mu.
queued []queuedMsg
// recentCIDs : identifiants d'envoi déjà acceptés. L'UI réessaie un envoi
// dont la réponse s'est perdue (tunnel) : avant la file, le 409 « déjà en
// cours » faisait office de dédoublonnage ; sans lui, le réessai mettrait
// le même message deux fois en file.
recentCIDs []string
}
// queuedMsg = un message mis en file pendant la génération. Caps et
// température sont ceux choisis à l'envoi : un tour démarré depuis la file doit
// s'exécuter avec ces réglages-là.
type queuedMsg struct {
text string
files []attachInfo
caps Caps
temp float64
}
var conv = func() *Conversation {
@@ -153,6 +175,7 @@ func LoadConversation() {
// Guérit les conversations d'avant le passage des captures en éphémère : un
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
conv.Messages = stripImageParts(conv.Messages)
go pruneChatImages()
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
conv.Generating = false
conv.cancel = nil
@@ -166,10 +189,12 @@ func (c *Conversation) loadFrom(b []byte) {
c.Stop()
c.mu.Lock()
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
if len(b) > 0 {
_ = json.Unmarshal(b, c)
c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement
}
go pruneChatImages() // plus rien ne référence les images de l'ancien fil
c.Generating = false
c.cancel = nil
c.epoch++
@@ -195,6 +220,9 @@ func reloadEncryptedStores() {
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
func (c *Conversation) persist() {
c.mu.Lock()
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
refImagesInMessages(c.Messages)
b, err := json.Marshal(c)
title := convSummary(c.Messages)
turns := 0
@@ -358,6 +386,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
c.appendDelta(epoch, map[string]any{"compact_noop": true})
return msgs, false
}
// Les pages mémoire lues ont été résumées avec le torse : un rappel liste
// leurs noms pour que le modèle les relise au besoin (AJEAN 0.14.0). Agent
// seulement — sans lui, pas d'outil mem_read pour y donner suite.
if caps.Agent {
compacted = remindReadMemPages(compacted, msgs)
}
overhead := ctxUsed - estimateTokens(msgs)
if overhead < 0 {
overhead = 0
@@ -495,10 +529,154 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
return nil
}
// ErrDupSend : cet envoi (même cid) a déjà été accepté — réessai d'un client
// qui n'a pas reçu la réponse. Ce n'est pas une erreur pour l'utilisateur.
var ErrDupSend = fmt.Errorf("envoi déjà reçu")
// seenCID enregistre cid et dit s'il avait déjà été vu. Appelant : mu tenu.
func (c *Conversation) seenCIDLocked(cid string) bool {
if cid == "" {
return false
}
for _, v := range c.recentCIDs {
if v == cid {
return true
}
}
c.recentCIDs = append(c.recentCIDs, cid)
if len(c.recentCIDs) > 64 {
c.recentCIDs = c.recentCIDs[len(c.recentCIDs)-64:]
}
return false
}
// EnqueueOrStart démarre un tour tout de suite si le moteur est libre, sinon
// MET EN FILE le message (AJEAN 0.14.0) — au lieu du 409 d'avant, qui obligeait
// à arrêter la réponse pour ajouter une précision. Seul un tour de CHAT accepte
// une file : une tâche planifiée qui occupe le modèle garde le refus
// (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir
// recentCIDs) ; un doublon renvoie ErrDupSend.
func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps Caps, temperature float64) (bool, error) {
c.mu.Lock()
if c.seenCIDLocked(cid) {
c.mu.Unlock()
return false, ErrDupSend
}
if c.Generating && c.runningTaskName == "" {
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
c.mu.Unlock()
return true, nil
}
c.mu.Unlock()
err := c.StartTurn(text, files, caps, temperature)
if errors.Is(err, ErrBusy) {
// Un autre tour a démarré entre le test ci-dessus et StartTurn (deux
// appareils qui envoient en même temps) : en file plutôt qu'un refus
// (AJEAN 0.17.4). Une tâche planifiée garde le refus : sa fin ne dépile rien.
c.mu.Lock()
if c.Generating && c.runningTaskName == "" {
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
c.mu.Unlock()
// Ce tour a pu finir entre-temps : sans relance, le message attendrait
// le prochain envoi.
c.startQueuedIfAny()
return true, nil
}
c.mu.Unlock()
}
if err != nil {
// Refusé (modèle pas prêt, tâche en cours) : le client pourra renvoyer
// ce même message, son cid ne doit pas rester marqué comme reçu.
c.mu.Lock()
for i, v := range c.recentCIDs {
if v == cid {
c.recentCIDs = append(c.recentCIDs[:i], c.recentCIDs[i+1:]...)
break
}
}
c.mu.Unlock()
}
return false, err
}
// drainQueued vide la file et renvoie les messages utilisateur à injecter dans
// le tour EN COURS (vue modèle), en les journalisant pour qu'ils apparaissent
// dans le fil sur tous les appareils. Appelé par runChat entre deux étapes.
// epoch garde contre un reset survenu entre-temps.
func (c *Conversation) drainQueued(epoch int) []Message {
c.mu.Lock()
if c.epoch != epoch || len(c.queued) == 0 {
c.mu.Unlock()
return nil
}
items := c.queued
c.queued = nil
c.mu.Unlock()
var out []Message
for _, q := range items {
delta := map[string]any{"user": q.text}
if m := chatModelName(); m != "" {
delta["model"] = m
}
if len(q.files) > 0 {
delta["files"] = q.files
}
c.appendDelta(epoch, delta)
prompt := q.text
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
out = append(out, Message{Role: "user", Content: userMessageContent(q.files, prompt)})
}
return out
}
// startQueuedIfAny démarre le prochain tour depuis la file, s'il en reste et
// que le moteur est libre. Appelé à la toute fin d'un tour : les messages
// envoyés après la dernière frontière d'étape sont ainsi traités sans que
// l'utilisateur ait à les renvoyer. Chaque tour lancé ainsi rappelle
// startQueuedIfAny à sa fin → la file se vide dans l'ordre. Renvoie true si un
// tour a démarré.
func (c *Conversation) startQueuedIfAny() bool {
c.mu.Lock()
if c.Generating || len(c.queued) == 0 {
c.mu.Unlock()
return false
}
q := c.queued[0]
c.queued = c.queued[1:]
epoch := c.epoch
c.mu.Unlock()
if err := c.StartTurn(q.text, q.files, q.caps, q.temp); err != nil {
// Modèle indisponible au moment de dépiler (rare) : on le dit dans le
// fil plutôt que de perdre le message en silence, et on tente le suivant.
c.appendDelta(epoch, map[string]any{"error": "message en attente non envoyé : " + err.Error()})
return c.startQueuedIfAny()
}
return true
}
// dropQueued abandonne la file (bouton stop : l'utilisateur reprend la main) et
// le signale au client, qui retire ses messages « en attente ».
func (c *Conversation) dropQueued(epoch int) {
c.mu.Lock()
n := len(c.queued)
c.queued = nil
c.mu.Unlock()
if n > 0 {
c.appendDelta(epoch, map[string]any{"queue_dropped": n})
}
}
// 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"])
cfg := ReadConfig()
if isExternalConfig(cfg) {
return strings.TrimSpace(cfg[extKeyModel]) // modèle distant, tel quel
}
m := strings.TrimSpace(cfg["MODEL"])
if m == "" {
return ""
}
@@ -539,15 +717,24 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
c.mu.Unlock()
c.persist()
// Notification Web Push : ce chemin (generate) ne sert QUE les tours
// File d'attente : interrompu par un stop, l'utilisateur reprend la main
// et les messages en file sont abandonnés. Sinon on démarre le prochain
// tour depuis la file — ceux qui n'ont pas pu être injectés en cours de
// route sont traités, dans l'ordre.
if ctx.Err() != nil {
c.dropQueued(epoch)
return
}
// Notification Web Push, seulement quand plus rien ne suit : un message
// en file qui démarre aussitôt n'est pas une « réponse prête », et sa
// propre fin notifiera. Ce chemin (generate) ne sert QUE les tours
// utilisateur — les tâches de fond passent par RunAutonomous et ont leur
// propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers
// le service de push ne doit pas retenir la fin du tour. Corps générique
// (pas d'extrait de réponse) : la notif transite par Apple/Google.
//
// ctx.Err() != nil = tour interrompu par un « stop » : pas de notification,
// l'utilisateur est là et a coupé volontairement.
if hasPushSubs() && ctx.Err() == nil {
// (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
// de notification après un « stop » (retour ci-dessus) : l'utilisateur
// est là et a coupé volontairement.
if !c.startQueuedIfAny() && hasPushSubs() {
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
}
}()
@@ -574,9 +761,20 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// 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}}, final...)
//
// Mode agent OFF = modèle BRUT (AJEAN 0.13.13) : ni l'un ni l'autre. Sans
// agent, on veut parler au modèle nu, comme à un llama-server direct. Le
// prompt du preset et l'index mémoire décrivent des outils que ce mode n'a
// pas : le modèle se mettait à écrire des <tool_call> en clair.
final := msgs
if caps.Agent {
final = append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
}
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
@@ -590,25 +788,23 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
// déjà la compaction), approximative mais jamais absente.
sawUsage := false
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
// pas de vérification du mode Code avant sa réponse.
asked := false
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
case ev.ToolUsed != nil:
// Le texte écrit AVANT cet appel d'outil est déjà rangé dans le message
// assistant porteur des tool_calls (extra). Le garder aussi dans la
// réponse finale le doublait dans l'historique vu par le modèle — du
// contexte gaspillé à chaque tour d'outil (AJEAN 0.17.4).
content.Reset()
tu := map[string]any{
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
}
// Taille réelle du résultat (l'aperçu n'en montre qu'un début) : le
// compteur ~N tok de la bulle affiche la vraie valeur, pas celle de
// l'aperçu.
if ev.ToolUsed.ResultChars > 0 {
tu["result_chars"] = ev.ToolUsed.ResultChars
}
// Référence pour charger le reste à la demande (bouton « voir plus »).
if ev.ToolUsed.ResultID != "" {
tu["result_id"] = ev.ToolUsed.ResultID
}
// Corps en cours de frappe : transitoire (l'état final est le diff), on
// ne l'ajoute que quand il est là pour ne pas gonfler chaque événement.
if ev.ToolUsed.Body != "" {
@@ -618,10 +814,19 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// pour que le diff soit encore là après un rafraîchissement.
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
tu["added"] = ev.ToolUsed.Added
tu["removed"] = ev.ToolUsed.Removed
}
if ev.ToolUsed.Image != "" {
tu["image"] = ev.ToolUsed.Image
}
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
if ev.ToolUsed.ResultChars > 0 {
tu["result_chars"] = ev.ToolUsed.ResultChars
}
if ev.ToolUsed.ResultID != "" {
tu["result_id"] = ev.ToolUsed.ResultID
}
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
@@ -652,6 +857,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.Ask != nil:
// Question structurée (outil ask) : carte à boutons dans l'UI.
asked = true
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.DropReasoning:
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
@@ -662,7 +868,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.appendDelta(epoch, map[string]any{"content": ev.Content})
}
return true // génération détachée : on ne s'interrompt jamais sur un abonné
})
}, func() []Message { return c.drainQueued(epoch) })
// Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats)
// PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle
@@ -701,7 +907,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// 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.codeVerifyLoop(ctx, caps, temperature, epoch, asked)
c.mu.Lock()
msgs = append([]Message(nil), c.Messages...)
ctxUsed = c.CtxUsed
@@ -803,6 +1009,7 @@ func (c *Conversation) Reset() {
c.epoch++
c.Generating = false
c.cancel = nil
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
c.cond.Broadcast()
c.mu.Unlock()
c.persist()
@@ -948,22 +1155,76 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
return out
}
// Subscribe diffuse les événements au client via emit : d'abord un REPLAY coalescé
// historyTailDefault : échanges rejoués à l'ouverture d'une session (reset) pour
// un client qui gère la pagination de l'historique.
const historyTailDefault = 20
// historyCut calcule où couper le rejeu pour ne garder que les `tail` derniers
// échanges (un échange commence à un événement `user`). Renvoie le Seq à partir
// duquel rejouer (exclu) et le nombre d'échanges masqués ; (0, 0) = tout
// rejouer. Repris d'AJEAN 0.15.7.
func historyCut(log []LogEvent, tail int) (int, int) {
if tail <= 0 {
return 0, 0
}
seen := 0
for i := len(log) - 1; i >= 0; i-- {
if _, ok := log[i].Delta["user"]; !ok {
continue
}
seen++
if seen == tail {
hidden := 0
for _, ev := range log[:i] {
if _, ok := ev.Delta["user"]; ok {
hidden++
}
}
if hidden == 0 {
return 0, 0
}
return log[i].Seq - 1, hidden
}
}
return 0, 0
}
// historyHead : événements qui annoncent un historique tronqué. Les états que
// les échanges masqués avaient posés et que le client garde (mode Chat/Code,
// critères du mode Code) sont réémis tels qu'ils étaient à la coupe — sans eux,
// une discussion en mode Code rouverte s'afficherait en mode Chat.
func historyHead(log []LogEvent, cut, hidden int) []map[string]any {
out := []map[string]any{{"history_more": hidden}}
var mode, crit map[string]any
for _, ev := range log {
if ev.Seq > cut {
break
}
if _, ok := ev.Delta["mode"]; ok {
mode = ev.Delta
}
if _, ok := ev.Delta["criteria"]; ok {
crit = ev.Delta
}
}
for _, d := range []map[string]any{mode, crit} {
if d != nil {
out = append(out, d)
}
}
return out
}
// Subscribe : abonnement sans pagination (rejeu complet).
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
c.SubscribeTail(ctx, from, -1, "", emit)
}
// SubscribeTail diffuse les événements au client via emit : d'abord un REPLAY coalescé
// de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement.
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
// replayTailMax borne le nombre d'événements REJOUÉS au chargement d'une
// discussion. Ce n'est pas une troncature du journal (rien n'est effacé) : le
// début reste sur disque et le client le demande d'un clic (full=true).
//
// Une discussion de plusieurs centaines de tours rejouait des dizaines de
// milliers d'événements à chaque ouverture d'onglet : quelques secondes de voile
// de chargement, et autant de bulles dans le DOM que le navigateur devait ensuite
// traîner à chaque rendu. On rejoue la fin — ce qu'on vient lire — et on annonce
// combien d'événements sont restés en arrière.
const replayTailMax = 4000
func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit func(map[string]any) bool) {
func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, convID string, emit func(map[string]any) bool) {
// Réveille les attentes de cond quand la connexion se ferme.
go func() {
<-ctx.Done()
@@ -995,23 +1256,42 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit
// 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.
//
// ⚠️ Même famille, cas plus sournois (AJEAN 0.14.0) : un AUTRE appareil a
// changé de discussion pendant que ce client était déconnecté, et son `from`
// est PLUS BAS que le dernier Seq de la nouvelle. Le rejeu greffait alors la
// fin de la nouvelle discussion sur le début de l'ancienne restée à l'écran —
// deux fils fusionnés jusqu'au prochain rafraîchissement. Le client renvoie
// l'id de la discussion qu'il affiche (convID) : s'il ne correspond plus, on
// lui ordonne d'abord de vider l'écran (reset), puis on rejoue tout.
curID := getStr(bkChat, ckActive)
staleConv := from > 0 && convID != "" && convID != curID
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
from = 0
staleConv = staleConv || convID != ""
}
last := from
events := coalesceReplay(snapshot, from)
// Replay borné : on ne garde que la fin, et on DIT au client combien
// d'événements manquent devant (il affiche « charger le début »). Jamais
// borné sur une reprise de flux (from > 0) : là, le client a déjà tout le
// début à l'écran et attend la SUITE.
if !full && from == 0 && len(events) > replayTailMax {
hidden := len(events) - replayTailMax
events = events[hidden:]
if !emit(map[string]any{"replay_truncated": hidden}) {
if staleConv {
from = 0
if !emit(map[string]any{"reset": true, "id": curID}) {
return
}
}
for _, ev := range events {
// Chargement initial d'un client paginé : on ne rejoue que la fin du fil,
// précédée de {history_more: N}. Sur un long fil d'agent, tout rejouer
// envoyait des Mo et bâtissait des milliers de bulles avant d'afficher quoi
// que ce soit.
if from == 0 && tail > 0 {
if cut, hidden := historyCut(snapshot, tail); hidden > 0 {
from = cut
for _, ev := range historyHead(snapshot, cut, hidden) {
if !emit(ev) {
return
}
}
}
}
last := from
for _, ev := range coalesceReplay(snapshot, from) {
if ctx.Err() != nil {
return
}
@@ -1022,7 +1302,7 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit
last = s
}
}
if !emit(map[string]any{"caught_up": true}) {
if !emit(map[string]any{"caught_up": true, "id": curID}) {
return
}
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
@@ -1055,10 +1335,27 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit
if c.epoch != epoch { // reset → on ordonne au client de nettoyer et on repart
epoch = c.epoch
last = 0
// Session ouverte (fil complet qui suit) chez un client paginé : on
// saute directement aux derniers échanges.
var head []map[string]any
if tail >= 0 {
if cut, hidden := historyCut(c.Log, historyTailDefault); hidden > 0 {
last = cut
// ctx_used vit dans le journal des échanges masqués : on le
// donne tel qu'il est maintenant, sinon la jauge resterait à 0.
head = append(historyHead(c.Log, cut, hidden), map[string]any{"ctx_used": c.CtxUsed})
}
}
c.mu.Unlock()
if !emit(map[string]any{"reset": true}) {
// id : la discussion désormais affichée (lue hors verrou — base).
if !emit(map[string]any{"reset": true, "id": getStr(bkChat, ckActive)}) {
return
}
for _, ev := range head {
if !emit(ev) {
return
}
}
c.mu.Lock()
continue
}
+6 -53
View File
@@ -27,7 +27,7 @@ func TestSubscribeReplayAndLive(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
got := make(chan int, 32)
go c.Subscribe(ctx, 0, false, func(m map[string]any) bool {
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
if s, ok := m["seq"].(int); ok {
got <- s
}
@@ -51,7 +51,7 @@ func TestSubscribeFromOffset(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
got := make(chan int, 8)
go c.Subscribe(ctx, 1, false, func(m map[string]any) bool {
go c.Subscribe(ctx, 1, func(m map[string]any) bool {
if s, ok := m["seq"].(int); ok {
got <- s
}
@@ -68,7 +68,7 @@ func TestResetNotifiesSubscribers(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resetSeen := make(chan bool, 4)
go c.Subscribe(ctx, 0, false, func(m map[string]any) bool {
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
if _, ok := m["reset"]; ok {
resetSeen <- true
}
@@ -202,7 +202,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
} {
ctx, cancel := context.WithCancel(context.Background())
got := make(chan int, 8)
go c.Subscribe(ctx, tc.from, false, func(m map[string]any) bool {
go c.Subscribe(ctx, tc.from, func(m map[string]any) bool {
if s, ok := m["seq"].(int); ok {
got <- s
}
@@ -216,7 +216,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
got := make(chan int, 8)
go c.Subscribe(ctx, 502, false, func(m map[string]any) bool {
go c.Subscribe(ctx, 502, func(m map[string]any) bool {
if s, ok := m["seq"].(int); ok {
got <- s
}
@@ -289,7 +289,7 @@ func TestSubscribeStaleFromCrossSession(t *testing.T) {
var got []map[string]any
done := make(chan struct{})
go func() {
c.Subscribe(ctx, 9999, false, func(ev map[string]any) bool {
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
got = append(got, ev)
if ev["caught_up"] != nil {
cancel()
@@ -309,50 +309,3 @@ func TestSubscribeStaleFromCrossSession(t *testing.T) {
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
}
}
// Replay borné : une discussion très longue ne rejoue que sa fin, annonce
// combien d'événements manquent, et rend TOUT si le client le demande.
func TestReplayTailIsBoundedUnlessFull(t *testing.T) {
c := &Conversation{}
c.cond = sync.NewCond(&c.mu)
for i := 0; i < replayTailMax+50; i++ {
c.Log = append(c.Log, LogEvent{Seq: i + 1, Delta: map[string]any{"user": "m"}})
}
collect := func(full bool) (events int, hidden int) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
c.Subscribe(ctx, 0, full, func(m map[string]any) bool {
if h, ok := m["replay_truncated"].(int); ok {
hidden = h
return true
}
if m["caught_up"] != nil {
cancel()
return false
}
if _, ok := m["pad"]; ok {
return true
}
events++
return true
})
}()
<-done
cancel()
return
}
events, hidden := collect(false)
if hidden != 50 {
t.Fatalf("50 événements auraient dû être annoncés masqués, obtenu %d", hidden)
}
if events > replayTailMax {
t.Fatalf("replay borné dépassé : %d événements rejoués", events)
}
if full, h := collect(true); full <= replayTailMax || h != 0 {
t.Fatalf("full=true doit tout rejouer sans bandeau, obtenu %d événements / %d masqués", full, h)
}
}
+83 -24
View File
@@ -5,12 +5,18 @@ package loki
// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
//
// Les lignes envoyées sont bornées (diffMaxShown), mais les compteurs +N / -N
// sont calculés AVANT la coupe et renvoyés à part : l'UI les comptait dans le
// diff tronqué, d'où un « +500 » pendant l'écriture qui retombait à « +120 ».
import "strings"
const (
diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent)
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
diffMaxLines = 400 // au-delà (partie modifiée), on compare sans détail (LCS trop lente)
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
diffContext = 3 // lignes identiques gardées autour d'un changement
diffOmitPrefix = "…"
)
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
@@ -19,26 +25,69 @@ type DiffLine struct {
Text string `json:"text"`
}
// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les
// lignes communes comme contexte : c'est ce qui rend le changement lisible
// quand l'IA ne touche qu'un mot au milieu d'un paragraphe.
func lineDiff(oldText, newText string) []DiffLine {
// lineDiff compare deux blocs de texte ligne à ligne et renvoie les lignes à
// afficher (bornées) avec le nombre RÉEL de lignes ajoutées et retirées.
//
// Les lignes communes en tête et en queue sont écartées avant la LCS : un
// changement d'une ligne dans un gros bloc reste un +1 -1 lisible (au lieu d'un
// « tout retiré, tout ajouté ») et le changement n'est pas repoussé hors des
// lignes affichées par des centaines de lignes de contexte.
func lineDiff(oldText, newText string) (lines []DiffLine, added, removed int) {
a := splitLines(oldText)
b := splitLines(newText)
// Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre
// simplement l'ancien en retrait et le nouveau en ajout.
if len(a) > diffMaxLines || len(b) > diffMaxLines {
out := make([]DiffLine, 0, len(a)+len(b))
for _, l := range a {
out = append(out, DiffLine{Op: "-", Text: l})
pre := 0
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
pre++
}
suf := 0
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
suf++
}
ma, mb := a[pre:len(a)-suf], b[pre:len(b)-suf]
var mid []DiffLine
if len(ma) > diffMaxLines || len(mb) > diffMaxLines {
// Partie modifiée énorme : pas de LCS (coût quadratique), l'ancien en
// retrait puis le nouveau en ajout.
mid = make([]DiffLine, 0, len(ma)+len(mb))
for _, l := range ma {
mid = append(mid, DiffLine{Op: "-", Text: l})
}
for _, l := range b {
out = append(out, DiffLine{Op: "+", Text: l})
for _, l := range mb {
mid = append(mid, DiffLine{Op: "+", Text: l})
}
} else {
mid = lcsDiff(ma, mb)
}
for _, d := range mid {
switch d.Op {
case "+":
added++
case "-":
removed++
}
return capLines(out)
}
// LCS : table des longueurs, puis remontée.
// Contexte autour du changement : quelques lignes, le reste résumé.
var out []DiffLine
if pre > diffContext {
out = append(out, DiffLine{Op: " ", Text: omitted(pre - diffContext)})
}
for _, l := range a[max(0, pre-diffContext):pre] {
out = append(out, DiffLine{Op: " ", Text: l})
}
out = append(out, mid...)
for _, l := range a[len(a)-suf : len(a)-suf+min(suf, diffContext)] {
out = append(out, DiffLine{Op: " ", Text: l})
}
if suf > diffContext {
out = append(out, DiffLine{Op: " ", Text: omitted(suf - diffContext)})
}
return capLines(out), added, removed
}
// lcsDiff : diff exact par plus longue sous-séquence commune.
func lcsDiff(a, b []string) []DiffLine {
n, m := len(a), len(b)
lcs := make([][]int, n+1)
for i := range lcs {
@@ -77,27 +126,37 @@ func lineDiff(oldText, newText string) []DiffLine {
for ; j < m; j++ {
out = append(out, DiffLine{Op: "+", Text: b[j]})
}
return capLines(out)
return out
}
// addedDiff présente un contenu entièrement nouveau (création d'une page).
func addedDiff(text string) []DiffLine {
lines := splitLines(text)
out := make([]DiffLine, 0, len(lines))
for _, l := range lines {
// addedDiff présente un contenu entièrement nouveau (création d'une page ou
// d'un fichier) : toutes ses lignes en ajout.
func addedDiff(text string) (lines []DiffLine, added int) {
ls := splitLines(text)
out := make([]DiffLine, 0, len(ls))
for _, l := range ls {
out = append(out, DiffLine{Op: "+", Text: l})
}
return capLines(out)
return capLines(out), len(ls)
}
func splitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
s = strings.TrimSuffix(s, "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func omitted(n int) string {
if n == 1 {
return diffOmitPrefix + "(1 ligne identique)"
}
return diffOmitPrefix + "(" + itoa(n) + " lignes identiques)"
}
// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
func capLines(d []DiffLine) []DiffLine {
if len(d) <= diffMaxShown {
@@ -105,7 +164,7 @@ func capLines(d []DiffLine) []DiffLine {
}
cut := len(d) - diffMaxShown
out := append([]DiffLine{}, d[:diffMaxShown]...)
return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"})
}
func itoa(n int) string {
+68
View File
@@ -0,0 +1,68 @@
package loki
import (
"strings"
"testing"
)
func numbered(n int, suffix string) string {
var b strings.Builder
for i := 1; i <= n; i++ {
b.WriteString("ligne " + itoa(i) + suffix + "\n")
}
return b.String()
}
// Un fichier de 500 lignes : +500, même si l'UI n'en reçoit que 120.
func TestAddedDiffCountsBeyondCap(t *testing.T) {
lines, added := addedDiff(numbered(500, ""))
if added != 500 {
t.Fatalf("added = %d, attendu 500", added)
}
if len(lines) > diffMaxShown+1 {
t.Fatalf("%d lignes envoyées, plafond %d", len(lines), diffMaxShown)
}
}
// Le saut de ligne final ne compte pas pour une ligne.
func TestAddedDiffTrailingNewline(t *testing.T) {
if _, n := addedDiff("a\nb\n"); n != 2 {
t.Fatalf("n = %d, attendu 2", n)
}
if _, n := addedDiff("a\nb"); n != 2 {
t.Fatalf("n = %d, attendu 2", n)
}
}
// Une ligne changée au milieu d'un gros bloc : +1 -1, et le changement est
// bien dans les lignes affichées (pas noyé sous le contexte).
func TestLineDiffSmallChangeInBigBlock(t *testing.T) {
old := numbered(800, "")
neu := strings.Replace(old, "ligne 600\n", "ligne 600 modifiée\n", 1)
lines, add, del := lineDiff(old, neu)
if add != 1 || del != 1 {
t.Fatalf("+%d -%d, attendu +1 -1", add, del)
}
var sawAdd, sawDel bool
for _, l := range lines {
sawAdd = sawAdd || (l.Op == "+" && l.Text == "ligne 600 modifiée")
sawDel = sawDel || (l.Op == "-" && l.Text == "ligne 600")
}
if !sawAdd || !sawDel {
t.Fatalf("le changement n'est pas dans le diff affiché : %v", lines)
}
if len(lines) > 2*diffContext+4 {
t.Fatalf("trop de contexte : %d lignes", len(lines))
}
}
func TestLineDiffCountsAreExact(t *testing.T) {
_, add, del := lineDiff("a\nb\nc\n", "a\nx\ny\nc\n")
if add != 2 || del != 1 {
t.Fatalf("+%d -%d, attendu +2 -1", add, del)
}
_, add, del = lineDiff(numbered(300, ""), numbered(300, " bis"))
if add != 300 || del != 300 {
t.Fatalf("+%d -%d, attendu +300 -300", add, del)
}
}
+80
View File
@@ -0,0 +1,80 @@
package loki
import (
"context"
"testing"
)
// historyCut ne garde que les N derniers échanges (un échange commence à `user`).
func TestHistoryCut(t *testing.T) {
var log []LogEvent
seq := 0
add := func(d map[string]any) { seq++; log = append(log, LogEvent{Seq: seq, Delta: d}) }
for i := 0; i < 5; i++ {
add(map[string]any{"user": "q"})
add(map[string]any{"content": "r"})
add(map[string]any{"turn_done": true})
}
// 5 échanges, seq 1..15 ; les 2 derniers commencent aux seq 10 et 13.
if cut, hidden := historyCut(log, 2); cut != 9 || hidden != 3 {
t.Fatalf("tail=2 : cut=%d hidden=%d, attendu 9 et 3", cut, hidden)
}
if cut, hidden := historyCut(log, 5); cut != 0 || hidden != 0 {
t.Fatalf("tail=5 (tout tient) : cut=%d hidden=%d, attendu 0 et 0", cut, hidden)
}
if cut, hidden := historyCut(log, 0); cut != 0 || hidden != 0 {
t.Fatalf("tail=0 : pas de coupure attendue (cut=%d hidden=%d)", cut, hidden)
}
// Les événements rejoués après la coupure commencent bien au 4e échange.
evs := coalesceReplay(log, 9)
if u, _ := evs[0]["user"].(string); u != "q" || evs[0]["seq"] != 10 {
t.Fatalf("premier événement rejoué inattendu : %v", evs[0])
}
}
// La coupe réémet le mode (Chat/Code) posé dans la partie masquée.
func TestHistoryHeadCarriesMode(t *testing.T) {
log := []LogEvent{
{Seq: 1, Delta: map[string]any{"mode": "code"}},
{Seq: 2, Delta: map[string]any{"user": "q1"}},
{Seq: 3, Delta: map[string]any{"user": "q2"}},
}
head := historyHead(log, 2, 1)
if len(head) != 2 || head[0]["history_more"] != 1 || head[1]["mode"] != "code" {
t.Fatalf("en-tête inattendu : %v", head)
}
}
// Un client paginé ne reçoit au chargement que les derniers échanges, précédés
// de {history_more} ; un ancien client (tail < 0) reçoit tout.
func TestSubscribeTailPaginates(t *testing.T) {
c := newTestConv()
for i := 0; i < 5; i++ {
c.appendDelta(c.epoch, map[string]any{"user": "q"})
c.appendDelta(c.epoch, map[string]any{"content": "r"})
}
collect := func(tail int) (more any, users int) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.SubscribeTail(ctx, 0, tail, "", func(m map[string]any) bool {
if v, ok := m["history_more"]; ok {
more = v
}
if _, ok := m["user"]; ok {
users++
}
if _, ok := m["caught_up"]; ok {
cancel()
return false
}
return true
})
return
}
if more, users := collect(2); more != 3 || users != 2 {
t.Fatalf("tail=2 : history_more=%v, %d échanges rejoués (attendu 3 et 2)", more, users)
}
if more, users := collect(-1); more != nil || users != 5 {
t.Fatalf("ancien client : history_more=%v, %d échanges (attendu rien et 5)", more, users)
}
}
+267
View File
@@ -0,0 +1,267 @@
package loki
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// chat_images.go — images d'une conversation stockées PAR RÉFÉRENCE.
//
// Une image vue par le modèle (pièce jointe, see_image, capture du navigateur) est
// une partie `image_url` en base64 dans un message. Gardée telle quelle dans
// l'historique, elle y pesait des Mo (une conversation de 14 messages : 36 Mo), et
// l'historique entier est réécrit à chaque fin de tour et relu à chaque ouverture.
//
// On la range donc UNE fois sur disque, sous LOKI_HOME/chatimg/<empreinte>.<ext>
// (hors du dossier de travail, que l'IA peut vider), et l'historique n'en garde
// que l'adresse « loki-img:<nom> ». Juste avant l'envoi au modèle,
// expandImageRefs remet les octets exacts (cache mémoire) : pour le modèle, rien
// ne change. Mêmes octets, même empreinte → aucune copie en double.
const imgRefScheme = "loki-img:"
func chatImgDir() string { return filepath.Join(LokiHome(), "chatimg") }
var imgExtByMime = map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"}
// storeChatImage range les octets d'une image et renvoie sa référence.
func storeChatImage(b []byte, mime string) (string, error) {
ext := imgExtByMime[mime]
if ext == "" {
ext = ".img"
}
sum := sha256.Sum256(b)
name := hex.EncodeToString(sum[:16]) + ext
dir := chatImgDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err != nil {
// Chiffrée comme le fil quand la mémoire l'est (même enveloppe que les
// pages). Verrouillée : refus, l'image reste en base64 dans le message.
enc, err := encodeMemContent(b)
if err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, p); err != nil {
return "", err
}
}
return imgRefScheme + name, nil
}
// Cache des data-URL reconstituées : un tour d'agent renvoie tout l'historique à
// CHAQUE étape, on ne relit pas le disque à chaque fois. Borné en taille.
var (
imgCacheMu sync.Mutex
imgCache = map[string]string{}
imgCacheOrder []string
imgCacheBytes int
)
const imgCacheMax = 64 << 20
func chatImageDataURL(ref string) (string, bool) {
name := strings.TrimPrefix(ref, imgRefScheme)
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return "", false
}
imgCacheMu.Lock()
if u, ok := imgCache[name]; ok {
imgCacheMu.Unlock()
return u, true
}
imgCacheMu.Unlock()
raw, err := os.ReadFile(filepath.Join(chatImgDir(), name))
if err != nil {
return "", false
}
b, err := decodeMemContent(raw)
if err != nil {
return "", false
}
mime := "image/jpeg"
for m, e := range imgExtByMime {
if strings.HasSuffix(name, e) {
mime = m
}
}
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
imgCacheMu.Lock()
if _, ok := imgCache[name]; !ok {
imgCache[name] = u
imgCacheOrder = append(imgCacheOrder, name)
imgCacheBytes += len(u)
for imgCacheBytes > imgCacheMax && len(imgCacheOrder) > 1 {
old := imgCacheOrder[0]
imgCacheOrder = imgCacheOrder[1:]
imgCacheBytes -= len(imgCache[old])
delete(imgCache, old)
}
}
imgCacheMu.Unlock()
return u, true
}
// chatImgKeep : âge minimal d'une image avant qu'on puisse l'effacer.
const chatImgKeep = 24 * time.Hour
// pruneChatImages efface les images de plus de chatImgKeep. Appelée au
// démarrage et à chaque bascule de discussion, c'est-à-dire quand le fil
// vivant vient d'être rechargé — et stripImageParts retire les images d'un fil
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
// fil resté ouvert longtemps sans bascule.
func pruneChatImages() {
dir := chatImgDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
cutoff := time.Now().Add(-chatImgKeep)
for _, e := range entries {
if e.IsDir() {
continue
}
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
// contentParts renvoie les parties d'un contenu multimodal, sous l'une ou l'autre
// forme (fraîche []map, ou relue du JSON []any).
func contentParts(content any) []map[string]any {
switch v := content.(type) {
case []map[string]any:
return v
case []any:
out := make([]map[string]any, 0, len(v))
for _, p := range v {
if m, ok := p.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
return nil
}
func partImageURL(p map[string]any) (map[string]any, string) {
if p["type"] != "image_url" {
return nil, ""
}
iu, _ := p["image_url"].(map[string]any)
if iu == nil {
return nil, ""
}
u, _ := iu["url"].(string)
return iu, u
}
// refImagesInMessages remplace EN PLACE les images en base64 des messages par des
// références (voir plus haut). Renvoie true si quelque chose a changé. Une image
// qui ne peut pas être rangée (disque plein…) reste en base64 : rien ne se perd.
//
// ⚠️ Écart avec l'amont : les parties modifiées sont RECOPIÉES, jamais mutées.
// persist peut tourner pendant qu'un runChat (compactage en vol, bascule de
// projet) sérialise encore ces mêmes maps : une écriture en place y déclenchait
// un « concurrent map read and map write », fatal pour le process.
func refImagesInMessages(msgs []Message) bool {
changed := false
for mi, m := range msgs {
parts := contentParts(m.Content)
var np []map[string]any
for pi, p := range parts {
_, u := partImageURL(p)
if !strings.HasPrefix(u, "data:image/") {
continue
}
i := strings.Index(u, ";base64,")
if i < 0 {
continue
}
b, err := base64.StdEncoding.DecodeString(u[i+8:])
if err != nil {
continue
}
ref, err := storeChatImage(b, u[5:i])
if err != nil {
continue
}
if np == nil {
np = append([]map[string]any(nil), parts...)
}
np[pi] = map[string]any{"type": "image_url", "image_url": map[string]any{"url": ref}}
}
if np != nil {
msgs[mi].Content = np
changed = true
}
}
return changed
}
// hasInlineImages : au moins une image encore en base64 dans ces messages ?
func hasInlineImages(msgs []Message) bool {
for _, m := range msgs {
for _, p := range contentParts(m.Content) {
if _, u := partImageURL(p); strings.HasPrefix(u, "data:image/") {
return true
}
}
}
return false
}
// expandImageRefs renvoie une COPIE des messages prête pour le modèle : chaque
// référence d'image redevient sa data-URL. Les messages sans référence sont
// partagés tels quels. Une image introuvable (dossier supprimé à la main) devient
// une mention textuelle plutôt qu'une requête qui échouerait.
func expandImageRefs(msgs []Message) []Message {
out := msgs
copied := false
for i, m := range msgs {
parts := contentParts(m.Content)
has := false
for _, p := range parts {
if _, u := partImageURL(p); strings.HasPrefix(u, imgRefScheme) {
has = true
break
}
}
if !has {
continue
}
if !copied {
out = append([]Message(nil), msgs...)
copied = true
}
np := make([]map[string]any, 0, len(parts))
for _, p := range parts {
_, u := partImageURL(p)
if !strings.HasPrefix(u, imgRefScheme) {
np = append(np, p)
continue
}
if d, ok := chatImageDataURL(u); ok {
np = append(np, map[string]any{"type": "image_url", "image_url": map[string]any{"url": d}})
} else {
np = append(np, map[string]any{"type": "text", "text": "[image indisponible : fichier supprimé]"})
}
}
m.Content = np
out[i] = m
}
return out
}
+95
View File
@@ -0,0 +1,95 @@
package loki
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Une image en base64 part sur disque et revient identique à l'envoi au modèle ;
// l'historique ne garde qu'une référence de quelques octets.
func TestImageRefsRoundTrip(t *testing.T) {
testHome(t)
raw := []byte(strings.Repeat("\x89PNG-fake-image-bytes", 5000))
url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
msgs := []Message{
{Role: "user", Content: []map[string]any{
{"type": "text", "text": "regarde"},
{"type": "image_url", "image_url": map[string]any{"url": url}},
}},
{Role: "assistant", Content: "vu"},
}
// Relu depuis le JSON (forme []any), comme après un redémarrage.
var reread []Message
b, _ := json.Marshal(msgs)
_ = json.Unmarshal(b, &reread)
for _, set := range [][]Message{msgs, reread} {
if !hasInlineImages(set) || !refImagesInMessages(set) || hasInlineImages(set) {
t.Fatal("l'image aurait dû être rangée par référence")
}
js, _ := json.Marshal(set)
if len(js) > 500 || !strings.Contains(string(js), imgRefScheme) {
t.Fatalf("historique encore lourd : %d octets", len(js))
}
out := expandImageRefs(set)
_, got := partImageURL(contentParts(out[0].Content)[1])
if got != url {
t.Fatal("l'image renvoyée au modèle diffère de l'originale")
}
// L'historique lui-même n'est pas modifié par l'expansion.
if _, u := partImageURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, imgRefScheme) {
t.Fatal("expandImageRefs ne doit pas toucher l'historique")
}
}
}
// Mémoire chiffrée : l'image rangée est chiffrée sur disque et revient intacte ;
// verrouillée, elle n'est pas écrite (elle reste en base64 dans le message).
func TestImageRefsChiffrees(t *testing.T) {
testHome(t)
clearMemDEK()
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
raw := []byte(strings.Repeat("\xff\xd8JPEG-fake", 3000))
ref, err := storeChatImage(raw, "image/jpeg")
if err != nil {
t.Fatalf("storeChatImage: %v", err)
}
disk, _ := os.ReadFile(filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme)))
if !looksEncrypted(disk) {
t.Fatal("image en clair sur disque alors que la mémoire est chiffrée")
}
u, ok := chatImageDataURL(ref)
if !ok || u != "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(raw) {
t.Fatal("image chiffrée non restituée à l'identique")
}
clearMemDEK()
if _, err := storeChatImage([]byte("autre image"), "image/png"); err == nil {
t.Fatal("mémoire verrouillée : l'image n'aurait pas dû être écrite")
}
}
// Les images de plus de chatImgKeep sont effacées, les récentes restent.
func TestPruneChatImages(t *testing.T) {
testHome(t)
vieille, _ := storeChatImage([]byte("vieille"), "image/png")
recente, _ := storeChatImage([]byte("récente"), "image/png")
pv := filepath.Join(chatImgDir(), strings.TrimPrefix(vieille, imgRefScheme))
old := time.Now().Add(-chatImgKeep - time.Hour)
if err := os.Chtimes(pv, old, old); err != nil {
t.Fatal(err)
}
pruneChatImages()
if _, err := os.Stat(pv); !os.IsNotExist(err) {
t.Fatal("la vieille image aurait dû être effacée")
}
if _, err := os.Stat(filepath.Join(chatImgDir(), strings.TrimPrefix(recente, imgRefScheme))); err != nil {
t.Fatal("la récente image a disparu")
}
}
+1 -2
View File
@@ -97,8 +97,7 @@ func webGrepTool() Tool {
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
// toolMaxOutput (8000) pour le shell. (La mémoire et MCP, eux, renvoient tout
// au modèle et laissent l'UI charger l'aperçu.) Sans ce
// toolMaxOutput (8000) pour le shell et mcpOutputCap pour MCP. Sans ce
// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
// déclenchait des compactages en cascade au milieu du raisonnement.
+163
View File
@@ -0,0 +1,163 @@
package loki
import (
"encoding/json"
"fmt"
"strings"
)
// chat_mem_pinned.go — RAPPEL des pages mémoire lues au compactage.
//
// Problème corrigé : quand le contexte est compacté, le torse (dont les résultats
// de mem_read) est résumé. Une page mémoire qui portait, par exemple, TOUTES LES
// RÈGLES à respecter pour une tâche était donc réduite à quelques mots dans le
// résumé — et le modèle, après compactage, « oubliait » les règles et partait en
// vrille.
//
// Choix retenu (le plus léger pour le contexte) : on ne garde RIEN verbatim. On
// injecte simplement, en tête de la conversation compactée, un petit rappel qui
// LISTE les pages que le modèle a lues (mem_read) et l'invite à en relire une si
// elle est pertinente, pour être sûr d'avoir toutes les infos avant de répondre. Le
// rappel ne pèse que les NOMS des pages (borné), le contenu complet reste à un
// mem_read de distance. Le comportement de compactage est par ailleurs inchangé.
//
// Le rappel ACCUMULE les noms à travers les compactages successifs : le rappel du
// compactage précédent est relu comme source, donc une page lue tôt (dont le
// mem_read d'origine a depuis été résumé) reste listée.
// memReminderPrefix ouvre le message de rappel. Sert aussi à le reconnaître (pour le
// relire comme source au compactage suivant, et pour le retirer avant reconstruction).
const memReminderPrefix = "[MEMORY PAGES REMINDER]"
// memReminderMaxNames borne le nombre de noms listés : au-delà, une conversation qui
// lit énormément de pages ne ferait pas enfler le rappel. On garde les plus RÉCENTES
// (les plus susceptibles de concerner la tâche en cours).
const memReminderMaxNames = 24
// collectReadPageNames rassemble les noms des pages mémoire LUES au fil de la
// conversation, dans l'ordre (première lecture d'abord). Deux sources, toutes deux
// balayées pour survivre aux compactages successifs :
// - les résultats de mem_read encore présents (résultat `tool` relié à sa page via
// l'argument `file` du tool_call correspondant), en ignorant les lectures ratées ;
// - le rappel injecté à un compactage PRÉCÉDENT (une fois le mem_read d'origine
// résumé, c'est la seule trace restante des noms).
func collectReadPageNames(msgs []Message) []string {
seen := map[string]bool{}
var order []string
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
return
}
seen[name] = true
order = append(order, name)
}
// tool_call_id → nom de page pour les appels mem_read (le résultat `tool` ne
// porte que l'id ; le nom vit dans l'argument `file` de l'appel de l'assistant).
readCall := map[string]string{}
for _, m := range msgs {
for _, tc := range m.ToolCalls {
if tc.Function.Name != "mem_read" || tc.ID == "" {
continue
}
var a map[string]any
if json.Unmarshal([]byte(tc.Function.Arguments), &a) == nil {
if f, _ := a["file"].(string); f != "" {
readCall[tc.ID] = f
}
}
}
}
for _, m := range msgs {
switch m.Role {
case "system":
for _, n := range parseReminderNames(msgText(m)) {
add(n)
}
case "tool":
name, ok := readCall[m.ToolCallID]
if !ok {
continue
}
if strings.HasPrefix(strings.TrimSpace(msgText(m)), "[erreur]") {
continue // lecture ratée : rien à rappeler
}
add(name)
}
}
return order
}
// parseReminderNames relit la liste de noms d'un message de rappel produit par
// buildReminderMessage. Renvoie nil si ce n'est pas un rappel.
func parseReminderNames(text string) []string {
if !strings.HasPrefix(text, memReminderPrefix) {
return nil
}
// Format : 1re ligne `[MEMORY PAGES REMINDER] … read earlier: a.md, b.md, c.md`
// (les noms après le dernier `: `, sans ponctuation finale) ; la consigne de
// relecture est sur la ou les lignes SUIVANTES, donc hors de la liste.
line := text
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
i := strings.LastIndex(line, ": ")
if i < 0 {
return nil
}
list := line[i+2:]
var out []string
for _, part := range strings.Split(list, ",") {
if n := strings.TrimSpace(part); n != "" {
out = append(out, n)
}
}
return out
}
// buildReminderMessage construit le message `system` de rappel. Renvoie ok=false si
// aucune page n'a été lue. Borné à memReminderMaxNames (on garde les plus récentes).
func buildReminderMessage(names []string) (Message, bool) {
if len(names) == 0 {
return Message{}, false
}
if len(names) > memReminderMaxNames {
names = names[len(names)-memReminderMaxNames:] // les plus récentes
}
content := fmt.Sprintf("%s Memory pages you read earlier: %s\nTheir full content is no longer inline after context compaction — if any of them is relevant to what you're doing, mem_read it again so you have all the rules and info you need before answering.",
memReminderPrefix, strings.Join(names, ", "))
return Message{Role: "system", Content: content}, true
}
// stripReminder retire le(s) message(s) de rappel d'une séquence (avant reconstruction).
func stripReminder(msgs []Message) []Message {
out := make([]Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == "system" {
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
continue
}
}
out = append(out, m)
}
return out
}
// remindReadMemPages reconstruit, à partir de l'historique AVANT compactage (source),
// le rappel des pages mémoire lues, et l'injecte en tête de la séquence COMPACTÉE. On
// retire d'abord un éventuel rappel hérité (porté par compacted depuis la tête
// protégée) pour repartir d'un rappel propre et à jour.
//
// Appelé uniquement en mode agent (les outils mem_* existent) et hors mémoire coupée.
// Sans page lue, no-op.
func remindReadMemPages(compacted, source []Message) []Message {
if memMode() == MemOff {
return compacted
}
names := collectReadPageNames(source)
compacted = stripReminder(compacted)
if m, ok := buildReminderMessage(names); ok {
return append([]Message{m}, compacted...)
}
return compacted
}
+121
View File
@@ -0,0 +1,121 @@
package loki
import (
"strings"
"testing"
)
// Une page mémoire lue (mem_read) doit être retrouvée par son nom (via l'argument
// `file` du tool_call), puis listée dans un rappel relisable.
func TestReminderRoundTrip(t *testing.T) {
msgs := []Message{
{Role: "user", Content: "applique les règles de la page"},
{Role: "assistant", ToolCalls: []ToolCall{{
ID: "call_1", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"regles-tache.md"}`},
}}},
{Role: "tool", ToolCallID: "call_1", Content: "RÈGLE 1 : toujours vouvoyer."},
}
names := collectReadPageNames(msgs)
if len(names) != 1 || names[0] != "regles-tache.md" {
t.Fatalf("nom de page mal collecté : %v", names)
}
m, ok := buildReminderMessage(names)
if !ok {
t.Fatalf("un rappel aurait dû être construit")
}
txt, _ := m.Content.(string)
if !strings.Contains(txt, "regles-tache.md") || !strings.Contains(txt, "mem_read") {
t.Fatalf("le rappel ne cite pas la page ou n'invite pas à relire : %q", txt)
}
// Le rappel doit se relire (noms) pour survivre au compactage suivant.
back := parseReminderNames(txt)
if len(back) != 1 || back[0] != "regles-tache.md" {
t.Fatalf("aller-retour du rappel cassé : %v", back)
}
}
// Le rappel doit ACCUMULER : un nom listé à un compactage précédent (dont le mem_read
// d'origine a été résumé/effacé) est retrouvé via le rappel système, même sans le
// résultat d'outil correspondant.
func TestReminderAccumulatesAcrossCompactions(t *testing.T) {
old, _ := buildReminderMessage([]string{"regles.md"})
msgs := []Message{
old, // rappel hérité (system)
{Role: "user", Content: "[CONTEXT COMPACTED] résumé…"},
{Role: "assistant", ToolCalls: []ToolCall{{
ID: "c2", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"procedure.md"}`},
}}},
{Role: "tool", ToolCallID: "c2", Content: "procédure détaillée"},
}
names := collectReadPageNames(msgs)
joined := strings.Join(names, ",")
if !strings.Contains(joined, "regles.md") {
t.Fatalf("la page héritée a été perdue : %v", names)
}
if !strings.Contains(joined, "procedure.md") {
t.Fatalf("la nouvelle page lue n'a pas été captée : %v", names)
}
}
// Une même page lue plusieurs fois n'est listée qu'une fois.
func TestReminderDedupes(t *testing.T) {
msgs := []Message{
{Role: "assistant", ToolCalls: []ToolCall{{ID: "a", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
{Role: "tool", ToolCallID: "a", Content: "v1"},
{Role: "assistant", ToolCalls: []ToolCall{{ID: "b", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
{Role: "tool", ToolCallID: "b", Content: "v2"},
}
if names := collectReadPageNames(msgs); len(names) != 1 || names[0] != "p.md" {
t.Fatalf("attendu une seule occurrence de p.md, obtenu %v", names)
}
}
// Une lecture ratée ([erreur] …) ne doit pas être rappelée.
func TestReminderSkipsErrors(t *testing.T) {
msgs := []Message{
{Role: "assistant", ToolCalls: []ToolCall{{ID: "e", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"absente.md"}`}}}},
{Role: "tool", ToolCallID: "e", Content: "[erreur] page introuvable"},
}
if names := collectReadPageNames(msgs); len(names) != 0 {
t.Fatalf("une lecture ratée ne doit pas être rappelée, obtenu %v", names)
}
}
// La liste de noms est bornée : au-delà du plafond, on garde les plus récentes.
func TestReminderCapsNames(t *testing.T) {
var names []string
for i := 0; i < memReminderMaxNames+10; i++ {
names = append(names, "page-"+string(rune('a'+i%26))+string(rune('0'+i/26))+".md")
}
m, ok := buildReminderMessage(names)
if !ok {
t.Fatalf("rappel attendu")
}
got := parseReminderNames(m.Content.(string))
if len(got) != memReminderMaxNames {
t.Fatalf("attendu %d noms max, obtenu %d", memReminderMaxNames, len(got))
}
// La toute dernière (plus récente) doit être conservée.
if got[len(got)-1] != names[len(names)-1] {
t.Fatalf("la page la plus récente doit être gardée : %q vs %q", got[len(got)-1], names[len(names)-1])
}
}
// stripReminder retire le rappel sans toucher au reste.
func TestStripReminder(t *testing.T) {
m, _ := buildReminderMessage([]string{"p.md"})
msgs := []Message{
m,
{Role: "system", Content: "Project context — autre chose"},
{Role: "user", Content: "salut"},
}
out := stripReminder(msgs)
if len(out) != 2 {
t.Fatalf("attendu 2 messages après strip, obtenu %d", len(out))
}
for _, mm := range out {
if s, _ := mm.Content.(string); strings.HasPrefix(s, memReminderPrefix) {
t.Fatalf("le rappel a survécu au strip")
}
}
}
+48 -22
View File
@@ -377,47 +377,69 @@ func MemDelete(name string) error {
}
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
// mode agent (shell). Trois modes :
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
// mode agent (shell). Réglé PAR PROJET (voir projectMemMode). Quatre modes :
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
// - MemAlways : proactif, INDEX INJECTÉ — l'index MEMORY.md (titres +
// accroches) est en tête de conversation, l'IA lit directement la bonne page.
// - MemSearchFirst : proactif, RECHERCHE D'ABORD — rien n'est injecté, l'IA
// appelle mem_search avant toute tâche. Prompt léger, démarrage plus rapide.
type MemMode string
const (
MemOff MemMode = "off"
MemOnDemand MemMode = "ondemand"
MemAlways MemMode = "always"
MemOff MemMode = "off"
MemOnDemand MemMode = "ondemand"
MemAlways MemMode = "always"
MemSearchFirst MemMode = "search"
)
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
// actuel). Toute valeur inconnue retombe sur always.
func memMode() MemMode {
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
// memProactive : l'IA gère sa mémoire d'elle-même (cherche/sauve sans qu'on le
// demande). Vrai pour les deux modes auto (injecté ou recherche).
func memProactive(m MemMode) bool { return m == MemAlways || m == MemSearchFirst }
// normalizeMemMode ramène une valeur de config à un mode connu. Défaut = always
// (préserve le comportement historique). "auto" est un alias d'always.
func normalizeMemMode(v string) MemMode {
switch strings.ToLower(strings.TrimSpace(v)) {
case "off":
return MemOff
case "ondemand":
return MemOnDemand
case "search":
return MemSearchFirst
default: // "always", "auto", "" et inconnus
return MemAlways
}
}
// setMemMode persiste le mode mémoire dans config.env.
func setMemMode(m MemMode) error {
return SetConfigKey("MEM_MODE", string(m))
// memMode renvoie le mode mémoire du PROJET ACTIF (AJEAN 0.13.12). Repli : un
// projet sans réglage propre (tous ceux d'avant) retombe sur l'ancien réglage
// global MEM_MODE de config.env — always par défaut. Rien ne change donc pour
// les projets existants tant qu'on ne touche pas à leur mode.
func memMode() MemMode {
if pm := projectMemMode(activeProjectSlug()); pm != "" {
return normalizeMemMode(pm)
}
return normalizeMemMode(ReadConfig()["MEM_MODE"])
}
// cmdMemory : loki memory [off|ondemand|always|status]
// setMemMode persiste le mode mémoire du PROJET ACTIF.
func setMemMode(m MemMode) error {
return setProjectMemMode(activeProjectSlug(), string(m))
}
// cmdMemory : loki memory [off|ondemand|always|search|status]
func cmdMemory(args []string) error {
sub := ""
if len(args) > 0 {
sub = strings.ToLower(strings.TrimSpace(args[0]))
}
label := map[MemMode]string{
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
MemAlways: "auto/injectée (index en tête de conversation, l'IA lit directement la bonne page)",
MemSearchFirst: "auto/recherche (rien d'injecté, l'IA cherche avant toute tâche — prompt léger)",
}
switch sub {
case "off":
@@ -428,20 +450,24 @@ func cmdMemory(args []string) error {
if err := setMemMode(MemOnDemand); err != nil {
return err
}
case "always":
case "always", "auto", "inject", "injected":
if err := setMemMode(MemAlways); err != nil {
return err
}
case "search", "recherche", "lazy":
if err := setMemMode(MemSearchFirst); err != nil {
return err
}
case "", "status":
m := memMode()
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
fmt.Printf("%s projet %s, mode: %s — %s\n", cyan("Mémoire"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
pages := MemList()
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
return nil
default:
return fmt.Errorf("usage: loki memory [off|ondemand|always|status]")
return fmt.Errorf("usage: loki memory [off|ondemand|always|search|status]")
}
m := memMode()
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
fmt.Printf("%s mémoire du projet %s : %s — %s\n", green("[ok]"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package loki
import (
"context"
"testing"
)
// Pendant une génération de chat, un envoi est mis en file ; il ressort à la
// frontière d'étape suivante, journalisé comme une bulle utilisateur.
func TestQueueDuringGeneration(t *testing.T) {
c := newTestConv()
c.Generating = true
queued, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7)
if err != nil || !queued {
t.Fatalf("mise en file attendue, got queued=%v err=%v", queued, err)
}
// Réessai du même envoi (réponse perdue) : pas de doublon.
if _, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7); err != ErrDupSend {
t.Fatalf("doublon attendu, got %v", err)
}
msgs := c.drainQueued(c.epoch)
if len(msgs) != 1 || msgs[0].Role != "user" {
t.Fatalf("un message user attendu, got %#v", msgs)
}
if len(c.Log) != 1 || c.Log[0].Delta["user"] != "précision" {
t.Fatalf("le message injecté doit apparaître dans le fil, journal : %#v", c.Log)
}
if again := c.drainQueued(c.epoch); len(again) != 0 {
t.Fatal("la file doit être vidée par drainQueued")
}
}
// Une tâche planifiée qui occupe le modèle garde le refus : sa fin ne dépile rien.
func TestQueueRefusedDuringTask(t *testing.T) {
c := newTestConv()
c.Generating = true
c.runningTaskName = "veille"
if queued, _ := c.EnqueueOrStart("", "x", nil, Caps{}, 0.7); queued {
t.Fatal("pas de file pendant une tâche planifiée")
}
}
// Stop : la file est abandonnée et le client en est prévenu.
func TestDropQueuedAnnounces(t *testing.T) {
c := newTestConv()
c.Generating = true
_, _ = c.EnqueueOrStart("", "a", nil, Caps{}, 0.7)
c.dropQueued(c.epoch)
if len(c.queued) != 0 || len(c.Log) != 1 || c.Log[0].Delta["queue_dropped"] != 1 {
t.Fatalf("abandon non signalé : file=%d journal=%#v", len(c.queued), c.Log)
}
}
// Un client dont la discussion affichée n'est plus l'active reçoit d'abord un
// reset, pour ne pas greffer le nouveau fil sur l'ancien.
func TestSubscribeStaleConvResets(t *testing.T) {
testHome(t)
_ = putStr(bkChat, ckActive, "c-nouvelle")
c := newTestConv()
for i := 0; i < 3; i++ {
c.appendDelta(c.epoch, map[string]any{"user": "q"})
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var first map[string]any
c.SubscribeTail(ctx, 2, -1, "c-ancienne", func(m map[string]any) bool {
if _, pad := m["pad"]; pad {
return true
}
if first == nil {
first = m
}
if _, ok := m["caught_up"]; ok {
cancel()
return false
}
return true
})
if first["reset"] != true || first["id"] != "c-nouvelle" {
t.Fatalf("reset attendu en premier, got %v", first)
}
}
+5
View File
@@ -117,6 +117,11 @@ var (
)
func engineSeesImages() bool {
// Preset externe : aucun /props à sonder, on s'en tient à la déclaration
// du preset (case « le modèle accepte les images »).
if externalActive() {
return externalVisionActive()
}
visionProbeMu.Lock()
defer visionProbeMu.Unlock()
if time.Since(visionProbeAt) < 10*time.Second {
+1
View File
@@ -318,6 +318,7 @@ func convDelete(id string) error {
// é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)
deleteToolResultsFor(id) // ses résultats « voir plus » partent avec elle
if id != getStr(bkChat, ckActive) {
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package loki
// chat_shell_hygiene.go — ce que le terminal rend au modèle, nettoyé (repris de
// shell.ts / shell-tail.ts d'OpenFox, 2.0.160) :
//
// - « cmd | tail -N » : le code de sortie devenait celui de tail (0), et un
// build en échec passait pour réussi. On retire le tail de la commande et
// on garde nous-mêmes les N dernières lignes : même sortie, vrai code.
// - séquences ANSI (couleurs, curseur) : du bruit en tokens, illisible ;
// - « cmd & » en mode Code : le process orphelin n'a ni sortie ni arrêt —
// bash_bg est fait pour ça.
import (
"regexp"
"strconv"
"strings"
)
// tailPipeRe : « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en
// toute fin de commande.
var tailPipeRe = regexp.MustCompile(`\s*\|\s*tail(?:\s+-n\s*(\d+)|\s+-(\d+))?\s*$`)
// splitTailPipe retire un « | tail -N » final. Renvoie la commande sans lui et
// N (0 = pas de tail ; 10 = tail sans nombre, sa valeur par défaut).
func splitTailPipe(command string) (string, int) {
m := tailPipeRe.FindStringSubmatchIndex(command)
if m == nil {
return command, 0
}
n := 10
for _, g := range []int{2, 4} {
if m[g] >= 0 {
if v, err := strconv.Atoi(command[m[g]:m[g+1]]); err == nil && v > 0 {
n = v
}
}
}
rest := strings.TrimSpace(command[:m[0]])
if rest == "" {
return command, 0
}
return rest, n
}
// shellANSIRe : séquences CSI (couleurs, curseur) et OSC (titres de fenêtre).
var shellANSIRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`)
func stripANSI(s string) string {
if !strings.Contains(s, "\x1b") {
return s
}
return shellANSIRe.ReplaceAllString(s, "")
}
// trailingBackground : la commande se termine par un « & » de mise en
// arrière-plan (pas « && »).
func trailingBackground(command string) bool {
c := strings.TrimSpace(command)
return strings.HasSuffix(c, "&") && !strings.HasSuffix(c, "&&")
}
+58
View File
@@ -0,0 +1,58 @@
package loki
import (
"context"
"runtime"
"strings"
"testing"
)
func TestSplitTailPipe(t *testing.T) {
cas := map[string]struct {
cmd string
n int
}{
"go build ./... 2>&1 | tail -20": {"go build ./... 2>&1", 20},
"make | tail -n 5": {"make", 5},
"make|tail -n40": {"make", 40},
"go test | tail": {"go test", 10},
"tail -f log.txt": {"tail -f log.txt", 0},
"cat a | tail -5 | grep x": {"cat a | tail -5 | grep x", 0},
"echo ok": {"echo ok", 0},
}
for in, want := range cas {
if cmd, n := splitTailPipe(in); cmd != want.cmd || n != want.n {
t.Errorf("%q → (%q, %d), attendu (%q, %d)", in, cmd, n, want.cmd, want.n)
}
}
if !trailingBackground("./serveur &") || trailingBackground("make && make test") || trailingBackground("ls 2>&1") {
t.Error("trailingBackground se trompe")
}
if got := stripANSI("\x1b[1;31mFAIL\x1b[0m ok"); got != "FAIL ok" {
t.Errorf("stripANSI = %q", got)
}
}
// « cmd | tail -N » rend le VRAI code de sortie, et seulement N lignes.
func TestShellTailGardeLeCodeDeSortie(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell POSIX")
}
withWorkspace(t)
out := runShell(context.Background(), "sh -c 'printf \"l1\nl2\nl3\n\"; exit 2' | tail -n 2", 10)
if !strings.HasPrefix(out, "exit: 2") || strings.Contains(out, "l1") || !strings.Contains(out, "l3") {
t.Fatalf("sortie = %q", out)
}
}
// Au délai dépassé, la sortie déjà produite est rendue.
func TestShellTimeoutGardeLaSortie(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell POSIX")
}
withWorkspace(t)
out := runShell(context.Background(), "echo avant; sleep 5", 1)
if !strings.Contains(out, "timeout") || !strings.Contains(out, "avant") {
t.Fatalf("sortie = %q", out)
}
}
+48 -7
View File
@@ -56,7 +56,7 @@ func baseSystemPrompt(caps Caps) string {
b.WriteString("\n\n" + rp)
}
}
if caps.Mem == MemAlways {
if memProactive(caps.Mem) {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
}
// PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête,
@@ -76,7 +76,15 @@ func baseSystemPrompt(caps Caps) string {
case MemAlways:
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes.\n")
// Mode INJECTÉ : l'index est en tête de contexte (mem_index.go), la
// recherche devient facultative — l'IA lit directement la bonne page.
b.WriteString("- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n")
case MemSearchFirst:
// Mode RECHERCHE : rien d'injecté, prompt léger — mais l'IA DOIT chercher
// elle-même, sinon elle ignore ce que sa mémoire contient.
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes. Nothing is preloaded: a search is the only way to know what you already know.\n")
case MemOnDemand:
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
}
@@ -96,7 +104,7 @@ func baseSystemPrompt(caps Caps) string {
// disent pas. Une ligne, pas trois : le budget du préambule est compté
// (TestSystemPromptStaysLean).
b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n")
if caps.Mem == MemAlways {
if caps.Mem == MemSearchFirst {
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
}
}
@@ -216,6 +224,10 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
if timeoutSec > toolMaxTimeout {
timeoutSec = toolMaxTimeout
}
// « … | tail -N » final : retiré, on garde nous-mêmes les N dernières lignes
// (chat_shell_hygiene.go) — sinon le code de sortie est celui de tail.
command, keepLines := splitTailPipe(command)
start := time.Now()
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
@@ -246,9 +258,27 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// issue connue était de redémarrer loki-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
// demandait un tail.
clean := func(s string) string { return tailOutput(stripANSI(s)) }
out := stdout.String()
if keepLines > 0 {
out = tailLines(out, keepLines)
}
out = clean(out)
errOut := clean(stderr.String())
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
// La sortie déjà produite reste utile (où le build en était, quel test
// bloquait) : on la rend au lieu d'un « timeout » sec (OpenFox).
msg := fmt.Sprintf("[timeout après %ds — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]", timeoutSec)
if out != "" {
msg += "\n\nstdout (partiel):\n" + out
}
if errOut != "" {
msg += "\n\nstderr (partiel):\n" + errOut
}
return msg
case errors.Is(parent.Err(), context.Canceled):
return "[commande interrompue]"
}
@@ -260,9 +290,8 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
return fmt.Sprintf("[erreur: %v]", err)
}
}
out := tailRunes(stdout.String(), toolMaxOutput)
errOut := tailRunes(stderr.String(), toolMaxOutput)
parts := []string{fmt.Sprintf("exit: %d", exit)}
// « exit: N » en tête : tasks_script.go s'y fie. La durée suit.
parts := []string{fmt.Sprintf("exit: %d · %s", exit, time.Since(start).Round(100*time.Millisecond))}
if out != "" {
parts = append(parts, "stdout:\n"+out)
}
@@ -383,6 +412,18 @@ func fileEdit(path, oldText, newText string) string {
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
}
// tailOutput garde la FIN d'une sortie de commande (toolMaxOutput runes) et dit
// explicitement ce qui a été coupé (AJEAN 0.15.5). La coupe était silencieuse :
// le modèle croyait voir toute la sortie, et la bulle affichait toujours
// ~2004 tok sans qu'on sache pourquoi.
func tailOutput(s string) string {
n := len([]rune(s))
if n <= toolMaxOutput {
return s
}
return fmt.Sprintf("[sortie tronquée : %d caractères au total, seuls les %d derniers sont gardés]\n", n, toolMaxOutput) + tailRunes(s, toolMaxOutput)
}
// tailRunes returns the last n runes of s (used to cap tool output).
func tailRunes(s string, n int) string {
r := []rune(s)
+102
View File
@@ -0,0 +1,102 @@
package loki
// code_alias.go — réparation des noms d'outils et d'arguments hallucinés.
//
// Les petits modèles ont appris d'autres agents : ils appellent read_file,
// str_replace, run_command, ou passent path / old_string au lieu de file /
// old. L'appel tombait sur « outil inconnu » ou sur un argument manquant, et
// le tour se perdait en allers-retours. On traduit vers l'outil RÉEL quand la
// cible existe dans ce tour (généralisation du transformSubAgentAliases
// d'OpenFox : un outil nommé d'après un sous-agent devient subagent{role}).
import (
"encoding/json"
"strings"
)
// toolNameAliases : nom halluciné → outil de Loki.
var toolNameAliases = map[string]string{
"read_file": "read", "view": "read", "cat": "read", "open_file": "read",
"write_file": "write", "create_file": "write",
"edit_file": "edit", "str_replace": "edit", "replace_in_file": "edit", "str_replace_editor": "edit",
"run_command": "bash", "shell": "bash", "run_shell": "bash", "execute_command": "bash", "terminal": "bash",
"search": "grep", "grep_search": "grep", "search_files": "grep",
"find_files": "glob", "list_files": "glob", "file_search": "glob",
}
// toolArgAliases : par outil, argument halluciné → argument attendu.
var toolArgAliases = map[string]map[string]string{
"read": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file"},
"write": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file", "contents": "content", "text": "content"},
"edit": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file",
"old_string": "old", "old_str": "old", "old_text": "old", "search": "old",
"new_string": "new", "new_str": "new", "new_text": "new", "replace": "new"},
"bash": {"cmd": "command"},
"bash_bg": {"cmd": "command"},
"grep": {"query": "pattern", "regex": "pattern"},
"glob": {"query": "pattern"},
}
// repairToolCall corrige en place le nom et les arguments d'un appel quand ils
// visent, sous un autre nom, un outil disponible dans ce tour. Renvoie true si
// l'appel a été modifié. Les arguments doivent déjà être du JSON valide.
func repairToolCall(tc *ToolCall, tools []Tool) bool {
have := make(map[string]bool, len(tools))
for _, t := range tools {
have[t.Function.Name] = true
}
name := tc.Function.Name
var args map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil || args == nil {
args = map[string]any{}
}
changed := false
if !have[name] {
lower := strings.ToLower(strings.TrimSpace(name))
if t := toolNameAliases[lower]; t != "" && have[t] {
name, changed = t, true
} else if have["subagent"] {
// Un sous-agent appelé comme un outil (« explorer », « code_reviewer »).
role := strings.ReplaceAll(lower, "_", "-")
for _, r := range subagentRoles {
if role == r {
if _, ok := args["role"]; !ok {
args["role"] = r
}
if _, ok := args["task"]; !ok {
for _, k := range []string{"prompt", "query", "question", "description"} {
if v, ok := args[k].(string); ok && v != "" {
args["task"] = v
delete(args, k)
break
}
}
}
name, changed = "subagent", true
break
}
}
}
}
for from, to := range toolArgAliases[name] {
v, ok := args[from]
if !ok {
continue
}
if _, exists := args[to]; !exists {
args[to] = v
changed = true
}
delete(args, from)
}
if !changed {
return false
}
b, err := json.Marshal(args)
if err != nil {
return false
}
tc.Function.Name = name
tc.Function.Arguments = string(b)
return true
}
+56
View File
@@ -0,0 +1,56 @@
package loki
import (
"encoding/json"
"testing"
)
func outils(noms ...string) []Tool {
var ts []Tool
for _, n := range noms {
ts = append(ts, Tool{Type: "function", Function: ToolFunction{Name: n}})
}
return ts
}
// Noms et arguments appris d'autres agents : traduits vers les outils de Loki.
func TestReparationDesAppelsHallucines(t *testing.T) {
tools := outils("read", "edit", "bash", "subagent")
cas := []struct {
name, args, wantName string
wantArgs map[string]any
}{
{"read_file", `{"path":"a.go"}`, "read", map[string]any{"file": "a.go"}},
{"str_replace", `{"file_path":"a.go","old_string":"x","new_string":"y"}`, "edit", map[string]any{"file": "a.go", "old": "x", "new": "y"}},
{"run_command", `{"cmd":"ls"}`, "bash", map[string]any{"command": "ls"}},
{"edit", `{"file":"a.go","old_str":"x","new":"y"}`, "edit", map[string]any{"file": "a.go", "old": "x", "new": "y"}},
{"code_reviewer", `{"prompt":"relis"}`, "subagent", map[string]any{"role": "code-reviewer", "task": "relis"}},
}
for _, c := range cas {
tc := ToolCall{Function: ToolCallFunc{Name: c.name, Arguments: c.args}}
if !repairToolCall(&tc, tools) {
t.Errorf("%s : non réparé", c.name)
continue
}
var got map[string]any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &got)
if tc.Function.Name != c.wantName || len(got) != len(c.wantArgs) {
t.Errorf("%s → %s %v", c.name, tc.Function.Name, got)
continue
}
for k, v := range c.wantArgs {
if got[k] != v {
t.Errorf("%s : %s=%v, attendu %v", c.name, k, got[k], v)
}
}
}
// Outil cible absent de ce tour, ou appel déjà correct : intact.
for _, tc := range []ToolCall{
{Function: ToolCallFunc{Name: "write_file", Arguments: `{"path":"a"}`}},
{Function: ToolCallFunc{Name: "read", Arguments: `{"file":"a"}`}},
} {
if repairToolCall(&tc, tools) {
t.Errorf("modifié à tort : %+v", tc)
}
}
}
+30 -8
View File
@@ -13,6 +13,7 @@ package loki
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
@@ -20,7 +21,7 @@ import (
type Criterion struct {
ID int `json:"id"`
Text string `json:"text"`
Status string `json:"status"` // pending | passed | failed
Status string `json:"status"` // pending | completed (le builder dit « fait ») | passed | failed
Note string `json:"note,omitempty"`
}
@@ -30,7 +31,7 @@ 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 {
if b, ok := getStoreBytes(bkChat, critKey(convID)); ok && len(b) > 0 {
_ = json.Unmarshal(b, &list)
}
return list
@@ -45,7 +46,7 @@ func critSave(convID string, list []Criterion) {
if err != nil {
return
}
_ = putBytes(bkChat, critKey(convID), b)
_ = putStoreBytes(bkChat, critKey(convID), b)
}
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
@@ -74,6 +75,18 @@ func critPending(list []Criterion) int {
return n
}
// critOpen : les critères que le builder n'a pas encore déclarés faits —
// pending, ou failed et pas encore repris (la reprise les remet à completed).
func critOpen(list []Criterion) []Criterion {
var out []Criterion
for _, c := range list {
if c.Status == "pending" || c.Status == "failed" || c.Status == "" {
out = append(out, c)
}
}
return out
}
// critRender : la liste formatée pour un prompt (builder ou verifier).
func critRender(list []Criterion) string {
var b strings.Builder
@@ -84,6 +97,8 @@ func critRender(list []Criterion) string {
mark = "[✓]"
case "failed":
mark = "[✗]"
case "completed":
mark = "[~]" // fait selon le builder, pas encore vérifié
}
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
if c.Note != "" {
@@ -100,15 +115,15 @@ func criteriaTool() Tool {
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.",
"action=add posits new criteria (texts[]), action=set updates one (id + status, optional note), " +
"action=list shows them, action=clear removes them all. Set criteria BEFORE building. Builder: mark each criterion completed once you have done AND checked it — verification only starts when nothing is left pending. Only the verification pass may mark passed or failed.",
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"}},
"status": map[string]any{"type": "string", "enum": []string{"pending", "completed", "passed", "failed"}},
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
},
"required": []string{"action"},
@@ -158,8 +173,15 @@ func toolCriteria(args map[string]any, allowPass bool) string {
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)
// id numérique ou chaîne (« "2" », « "#2" ») : les petits modèles
// écrivent volontiers l'id entre guillemets, qui tombait sinon à #0.
id := 0
switch v := args["id"].(type) {
case float64:
id = int(v)
case string:
id, _ = strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(v), "#"))
}
status, _ := args["status"].(string)
note, _ := args["note"].(string)
if status == "passed" && !allowPass {
+59 -7
View File
@@ -25,8 +25,10 @@ func gitStatusTool() Tool {
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{},
"type": "object",
"properties": map[string]any{
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
},
},
},
}
@@ -42,6 +44,7 @@ func gitDiffTool() Tool {
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
},
},
},
@@ -66,15 +69,56 @@ func gitCloneTool() Tool {
}
}
// gitRepoDir : où lancer git_status / git_diff. La discussion n'est souvent PAS
// un dépôt : git_clone range le dépôt dans un sous-dossier, et git, lancé à la
// racine, échouait (« not a git repository ») — alors que le vérificateur a
// pour consigne de commencer par git_diff. Ordre : le sous-dossier demandé
// (dir), la racine si c'est un dépôt, sinon l'unique sous-dossier qui en est
// un. Plusieurs candidats : on demande de choisir.
func gitRepoDir(dir string) (string, error) {
base := agentCwd()
if dir = strings.TrimSpace(dir); dir != "" {
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
return "", fmt.Errorf("dossier invalide : %s", dir)
}
return filepath.Join(base, dir), nil
}
if _, err := os.Stat(filepath.Join(base, ".git")); err == nil {
return base, nil
}
entries, _ := os.ReadDir(base)
var repos []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(base, e.Name(), ".git")); err == nil {
repos = append(repos, e.Name())
}
}
switch len(repos) {
case 0:
return base, nil // git dira lui-même « not a git repository »
case 1:
return filepath.Join(base, repos[0]), nil
}
return "", fmt.Errorf("plusieurs dépôts ici (%s) : précise dir", strings.Join(repos, ", "))
}
// runGit exécute git dans le dossier de la discussion, sortie bornée.
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
return runGitIn(ctx, agentCwd(), timeout, args...)
}
// runGitIn exécute git dans dir, sortie bornée.
func runGitIn(ctx context.Context, dir string, 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()
cmd.Dir = dir
// 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")
@@ -95,24 +139,32 @@ func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
return s
}
func toolGitStatus(ctx context.Context) string {
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
func toolGitStatus(ctx context.Context, args map[string]any) string {
dir, err := gitRepoDir(str(args["dir"]))
if err != nil {
return "[erreur] " + err.Error()
}
return runGitIn(ctx, dir, 20*time.Second, "status", "--porcelain=v1", "--branch")
}
func toolGitDiff(ctx context.Context, args map[string]any) string {
dir, err := gitRepoDir(str(args["dir"]))
if err != nil {
return "[erreur] " + err.Error()
}
// 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...)
out := runGitIn(ctx, dir, 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...)
out = runGitIn(ctx, dir, 30*time.Second, fallback...)
}
return out
}
+55
View File
@@ -0,0 +1,55 @@
package loki
// code_instructions.go — consignes du dépôt (AGENTS.md, CLAUDE.md) en mode Code.
// Repris de context/instructions.ts d'OpenFox.
//
// Un dépôt qui documente ses commandes de build et de test, ses conventions,
// ses pièges, le fait dans ces fichiers. Sans eux, le modèle les redécouvrait
// à coups de read et de bash — quand il les redécouvrait. Le contenu part avec
// le contexte du projet : déplacé dans le premier message utilisateur par
// normalizeSystemMessages, il ne touche pas au préfixe système (cache).
import (
"os"
"path/filepath"
"strings"
)
const codeInstructionsPrefix = "Repository instructions"
// codeInstructionsMax : au-delà, on tronque (≈ 1k tokens) — ces fichiers
// peuvent être longs, et le contexte est compté.
const codeInstructionsMax = 4000
// codeInstructionFiles : par ordre de préférence ; le premier trouvé gagne.
var codeInstructionFiles = []string{"AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"}
// codeInstructionsMessage renvoie les consignes du dépôt de la discussion
// (racine, ou dépôt cloné détecté par gitRepoDir), ok=false s'il n'y en a pas.
func codeInstructionsMessage(caps Caps) (Message, bool) {
if !caps.Code {
return Message{}, false
}
dirs := []string{agentCwd()}
if d, err := gitRepoDir(""); err == nil && d != dirs[0] {
dirs = append([]string{d}, dirs...)
}
for _, dir := range dirs {
for _, name := range codeInstructionFiles {
b, err := os.ReadFile(filepath.Join(dir, name))
if err != nil || len(strings.TrimSpace(string(b))) == 0 {
continue
}
s := strings.TrimSpace(string(b))
if r := []rune(s); len(r) > codeInstructionsMax {
s = string(r[:codeInstructionsMax]) + "\n…[tronqué — lis le fichier pour la suite]"
}
rel := name
if r, err := filepath.Rel(agentCwd(), filepath.Join(dir, name)); err == nil {
rel = filepath.ToSlash(r)
}
return Message{Role: "system", Content: codeInstructionsPrefix + " (" + rel + ") — follow them:\n\n" + s}, true
}
}
return Message{}, false
}
+34
View File
@@ -0,0 +1,34 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// AGENTS.md du dépôt cloné part avec le contexte en mode Code, déplacé dans le
// premier message utilisateur (préfixe système intact).
func TestConsignesDuDepot(t *testing.T) {
withWorkspace(t)
if _, ok := codeInstructionsMessage(Caps{Code: true}); ok {
t.Fatal("consignes trouvées dans un dossier vide")
}
repo := filepath.Join(agentCwd(), "projet")
_ = os.MkdirAll(filepath.Join(repo, ".git"), 0o755)
if err := os.WriteFile(filepath.Join(repo, "AGENTS.md"), []byte("Build : make test\n"+strings.Repeat("x", 5000)), 0o644); err != nil {
t.Fatal(err)
}
if _, ok := codeInstructionsMessage(Caps{}); ok {
t.Fatal("consignes injectées hors mode Code")
}
m, ok := codeInstructionsMessage(Caps{Code: true})
s := msgText(m)
if !ok || !strings.Contains(s, "make test") || !strings.Contains(s, "projet/AGENTS.md") || !strings.Contains(s, "tronqué") {
t.Fatalf("message = %.200q", s)
}
out := normalizeSystemMessages([]Message{{Role: "system", Content: "base"}, m, {Role: "user", Content: "fais-le"}})
if msgText(out[0]) != "base" || !strings.Contains(msgText(out[1]), "make test") {
t.Fatalf("consignes restées dans le système : %+v", out)
}
}
+86
View File
@@ -0,0 +1,86 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// Le schéma de write annonce le chemin AVANT le contenu.
func TestSchemaWriteCheminDAbord(t *testing.T) {
b, _ := json.Marshal(writeTool().Function.Parameters)
s := string(b)
if i, j := strings.Index(s, `"file"`), strings.Index(s, `"content"`); i < 0 || j < 0 || i > j {
t.Fatalf("ordre des propriétés : %s", s)
}
}
// write sur un fichier existant jamais lu : refusé dès que le chemin est
// complet, sans attendre le contenu ; le fichier reste intact.
func TestPreVolCoupeLEcritureRefusee(t *testing.T) {
withWorkspace(t)
cible := filepath.Join(agentCwd(), "main.go")
if err := os.WriteFile(cible, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
var n int32
var second []Message
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if atomic.AddInt32(&n, 1) == 1 {
chunk := func(args string) {
esc := strings.ReplaceAll(args, `"`, `\"`)
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"write","arguments":"` + esc + `"}}]}}]}` + "\n\n"))
w.(http.Flusher).Flush()
}
chunk(`{"file":"main.go",`)
chunk(`"content":"package main // début`)
time.Sleep(300 * time.Millisecond)
chunk(` FIN-JAMAIS-LUE"}`)
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
return
}
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
second = body.Messages
_, _ = w.Write([]byte(sseChunk("je lis d'abord")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
var results []string
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "réécris main.go"}}, 0.7, Caps{Agent: true, Code: true}, func(ev StreamEvent) bool {
if ev.ToolUsed != nil && ev.ToolUsed.Done {
results = append(results, ev.ToolUsed.Result)
}
if ev.ToolUsed != nil && strings.Contains(ev.ToolUsed.Body, "FIN-JAMAIS-LUE") {
t.Error("le contenu a continué d'arriver après le refus")
}
return true
}); err != nil {
t.Fatal(err)
}
if len(results) != 1 || !strings.Contains(results[0], "[refusé]") || !strings.Contains(results[0], "interrompue") {
t.Fatalf("résultats = %q", results)
}
if b, _ := os.ReadFile(cible); string(b) != "package main\n" {
t.Fatalf("fichier modifié : %q", b)
}
if len(second) < 2 || second[len(second)-1].Role != "tool" || !strings.Contains(msgText(second[len(second)-1]), "[refusé]") {
t.Fatalf("le refus n'a pas été rendu au modèle : %+v", second)
}
}
+41 -13
View File
@@ -14,7 +14,9 @@ package loki
import (
"encoding/json"
"regexp"
"strings"
"sync"
"unicode/utf8"
)
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
@@ -29,6 +31,10 @@ var defaultRetryPatterns = []string{
`\bfunctions\.\w+\s*\(`,
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
`\[TOOL_REQUEST\]`,
// Format XML de Qwen3-Coder, quand le gabarit du moteur ne l'a pas converti
// en tool_calls (OpenFox 2.0.15x) : <function=bash><parameter=command>…
`<function=\w+>`,
`<parameter=\w+>`,
}
// Compilés à la première utilisation, pas à l'init du package : la surcharge
@@ -67,18 +73,40 @@ func compileRetryPatterns() []*regexp.Regexp {
// 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
return textualToolCallSnippet(content) != ""
}
// 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."
// textualToolCallSnippet renvoie l'extrait fautif (le motif reconnu et un peu
// de ce qui le suit), "" si aucun. Cité dans la consigne corrective : le
// modèle voit CE qu'il a mal écrit au lieu d'une remontrance abstraite.
func textualToolCallSnippet(content string) string {
if content == "" {
return ""
}
for _, re := range retryPatterns() {
if loc := re.FindStringIndex(content); loc != nil {
end := loc[1] + 80
if end > len(content) {
end = len(content)
}
for end < len(content) && !utf8.RuneStart(content[end]) {
end++
}
return strings.TrimSpace(content[loc[0]:end])
}
}
return ""
}
// maxPatternRetries : relances « appel écrit en texte » consécutives. Le
// compteur repart à zéro après chaque appel d'outil réussi (OpenFox) : un long
// tour d'agent peut rater la syntaxe plusieurs fois, mais pas en boucle.
const maxPatternRetries = 3
// retryCorrective : le message réinjecté pour relancer le tour, avec l'extrait
// fautif.
func retryCorrective(snippet string) string {
return "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n" + snippet +
"\nTool 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."
}
+68 -1
View File
@@ -1,6 +1,16 @@
package loki
import "testing"
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
// Les appels d'outils écrits en texte doivent être détectés…
func TestAppelOutilTextuelDetecte(t *testing.T) {
@@ -32,3 +42,60 @@ func TestReponseNormaleNonRelancee(t *testing.T) {
}
}
}
// Format XML de Qwen3-Coder resté en texte : détecté, et l'extrait est cité.
func TestAppelOutilQwenXMLDetecte(t *testing.T) {
s := "Je regarde.\n<function=bash>\n<parameter=command>ls -la</parameter>\n</function>"
snip := textualToolCallSnippet(s)
if snip == "" || !strings.HasPrefix(snip, "<function=bash>") {
t.Fatalf("extrait = %q", snip)
}
if textualToolCall("Un <div class=\"x\"> et un <param name=\"a\"> en HTML.") {
t.Fatal("HTML ordinaire pris pour un appel d'outil")
}
}
// Le faux appel est repéré PENDANT le flux : la génération est coupée (la suite
// n'arrive jamais à l'écran) et la relance cite l'extrait fautif.
func TestAppelTextuelCoupeLeFlux(t *testing.T) {
testHome(t)
var n int32
var second string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if atomic.AddInt32(&n, 1) == 1 {
_, _ = w.Write([]byte(sseChunk("Je lance <tool_call>")))
w.(http.Flusher).Flush()
time.Sleep(300 * time.Millisecond)
_, _ = w.Write([]byte(sseChunk("FIN-INUTILE")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
return
}
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
second = msgText(body.Messages[len(body.Messages)-1])
_, _ = w.Write([]byte(sseChunk("ok")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
var content strings.Builder
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "liste"}}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
content.WriteString(ev.Content)
return true
}); err != nil {
t.Fatal(err)
}
if strings.Contains(content.String(), "FIN-INUTILE") {
t.Fatal("le flux n'a pas été coupé sur l'appel écrit en texte")
}
if !strings.Contains(second, "<tool_call>") || !strings.Contains(second, "WRITTEN AS TEXT") {
t.Fatalf("relance sans l'extrait fautif : %q", second)
}
}
+11 -1
View File
@@ -30,6 +30,9 @@ import (
// permettrait au builder de se décerner son propre satisfecit.
var subagentRoles = []string{"explorer", "code-reviewer", "planner"}
// subagentTemperature : voir toolSubagent.
const subagentTemperature = 0.6
func isSubagentRole(r string) bool {
for _, x := range subagentRoles {
if x == r {
@@ -102,7 +105,14 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
var out strings.Builder
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
if _, err := runChat(ctx, msgs, 0, caps, func(ev StreamEvent) bool {
// Température 0.6 et non 0 : en glouton, Qwen avec réflexion tourne vite en
// boucle (OpenFox l'a relevé). Le TEMP du preset, s'il est posé, l'emporte.
// Seul le texte écrit APRÈS le dernier outil revient au builder : la
// narration entre deux lectures n'est pas le rapport.
if _, err := runChat(ctx, msgs, subagentTemperature, caps, func(ev StreamEvent) bool {
if ev.ToolUsed != nil {
out.Reset()
}
if ev.Content != "" {
out.WriteString(ev.Content)
}
+25
View File
@@ -157,3 +157,28 @@ func TestEditCRLFMultiligne(t *testing.T) {
t.Fatalf("résultat inattendu : %q", b)
}
}
// git_status / git_diff trouvent le dépôt cloné dans un sous-dossier de la
// discussion, et demandent de choisir quand il y en a plusieurs.
func TestGitRepoDirDetecteLeClone(t *testing.T) {
ws := withWorkspace(t)
if d, err := gitRepoDir(""); err != nil || d != agentCwd() {
t.Fatalf("sans dépôt : %q, %v", d, err)
}
if err := os.MkdirAll(filepath.Join(agentCwd(), "projet", ".git"), 0o755); err != nil {
t.Fatal(err)
}
if d, err := gitRepoDir(""); err != nil || filepath.Base(d) != "projet" {
t.Fatalf("un clone : %q, %v (ws %s)", d, err, ws)
}
_ = os.MkdirAll(filepath.Join(agentCwd(), "autre", ".git"), 0o755)
if _, err := gitRepoDir(""); err == nil {
t.Fatal("deux dépôts : il fallait demander dir")
}
if d, err := gitRepoDir("autre"); err != nil || filepath.Base(d) != "autre" {
t.Fatalf("dir explicite : %q, %v", d, err)
}
if _, err := gitRepoDir("../dehors"); err == nil {
t.Fatal("dir hors de la discussion accepté")
}
}
+70 -11
View File
@@ -25,10 +25,19 @@ import (
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
const codeMaxFixTurns = 2
// codeMaxContinue : relances du builder qui a rendu la main avec des critères
// encore ouverts (« ensuite je vais… ») avant de vérifier quand même. Reprise
// du buildAgentNudge d'OpenFox : une passe de vérification sur un travail
// inachevé, c'est un prefill complet, le cache KV du fil évincé, et des
// « failed » qui ne disent que « pas encore fait ».
const codeMaxContinue = 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 {
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère), ni si le
// tour s'est terminé sur une question à l'utilisateur (asked) : sa réponse
// passe avant toute vérification.
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int, asked bool) {
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil || asked {
return
}
convID := convEnsureActive()
@@ -40,6 +49,24 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
if caps.Role == "planner" {
return
}
// Le builder vérifie lui-même puis marque « completed » ce qu'il estime
// fait. Tant qu'il reste des critères ouverts, on le relance plutôt que de
// vérifier un travail inachevé.
for n := 0; n < codeMaxContinue; n++ {
open := critOpen(list)
if len(open) == 0 {
break
}
msg := "Not done yet — these acceptance criteria are still open:\n" + critRender(open) +
"\n\nContinue working on them. Once one is done and checked, mark it completed (criteria action=set, status=completed). If you need the user's decision, use ask."
if c.runBuilderTurn(ctx, caps, temperature, epoch, msg) || ctx.Err() != nil {
return // question posée à l'utilisateur, ou arrêt
}
list = critList(convID)
if critAllPassed(list) {
return
}
}
for fix := 0; ; fix++ {
if ctx.Err() != nil {
return
@@ -59,7 +86,9 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
return
}
c.runFixTurn(ctx, caps, temperature, epoch, list)
if c.runFixTurn(ctx, caps, temperature, epoch, list) {
return // le builder attend une réponse de l'utilisateur
}
}
}
@@ -91,38 +120,59 @@ func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature
}
// 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) {
// critères en échec. Sa trace est persistée comme un tour ordinaire. Renvoie
// true si le builder a posé une question à l'utilisateur (ask).
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) bool {
var failed []Criterion
for _, cr := range list {
if cr.Status != "passed" {
failed = append(failed, cr)
}
}
return c.runBuilderTurn(ctx, caps, temperature, epoch, "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. Mark each fixed criterion completed again.")
}
// runBuilderTurn relance le BUILDER sur l'historique normal avec une consigne
// (correction après vérification, ou relance sur des critères ouverts). Sa
// trace est persistée comme un tour ordinaire. Renvoie true si le builder a
// posé une question à l'utilisateur (ask) : la boucle doit alors s'arrêter.
func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperature float64, epoch int, instruction string) (asked bool) {
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."}
fixMsg := Message{Role: "user", Content: instruction}
c.mu.Lock()
if c.epoch != epoch {
c.mu.Unlock()
return
return false
}
c.Messages = append(c.Messages, fixMsg)
msgs := append([]Message(nil), c.Messages...)
c.mu.Unlock()
final := msgs
// Même contexte que le tour de build : projet (description, index mémoire,
// trackers) et consignes du dépôt. Sans eux, la reprise changeait le début
// du premier message utilisateur — tout le cache de prompt à refaire.
final := append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
var content strings.Builder
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
if ev.ToolUsed != nil {
content.Reset() // déjà dans le message tool_calls (voir generate)
}
if ev.Ask != nil {
asked = true
}
c.forwardStream(ev, epoch)
return true
})
@@ -135,6 +185,7 @@ func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature fl
}
c.mu.Unlock()
c.persist()
return asked
}
// forwardStream relaie les événements d'un runChat secondaire (vérification,
@@ -154,10 +205,18 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
}
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
tu["added"] = ev.ToolUsed.Added
tu["removed"] = ev.ToolUsed.Removed
}
if ev.ToolUsed.Image != "" {
tu["image"] = ev.ToolUsed.Image
}
if ev.ToolUsed.ResultChars > 0 {
tu["result_chars"] = ev.ToolUsed.ResultChars
}
if ev.ToolUsed.ResultID != "" {
tu["result_id"] = ev.ToolUsed.ResultID
}
c.appendDelta(epoch, map[string]any{"tool_used": tu})
case ev.Ask != nil:
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
+110
View File
@@ -0,0 +1,110 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// moteurScripte : faux llama-server qui répond selon le DERNIER message reçu.
// Un message `tool` en queue → texte de clôture ; sinon la règle dont le motif
// figure dans le dernier message utilisateur → appel d'outil criteria.
func moteurScripte(t *testing.T, regles map[string]string) (port string, vus *[]string) {
t.Helper()
var mu sync.Mutex
var log []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
last := body.Messages[len(body.Messages)-1]
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
args := ""
if last.Role == "user" {
txt := msgText(last)
mu.Lock()
log = append(log, txt)
mu.Unlock()
for motif, a := range regles {
if strings.Contains(txt, motif) {
args = a
}
}
}
if args != "" {
esc := strings.ReplaceAll(args, `"`, `\"`)
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"criteria","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
} else {
_, _ = w.Write([]byte(sseChunk("ok")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
}
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
return u.Port(), &log
}
// Le builder rend la main avec un critère encore ouvert : on le RELANCE (il le
// marque completed) avant de vérifier, au lieu de vérifier un travail
// inachevé (repris du buildAgentNudge d'OpenFox).
func TestVerificationAttendQueLeBuilderAitFini(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
port, vus := moteurScripte(t, map[string]string{
"Not done yet": `{"action":"set","id":"1","status":"completed"}`,
"Verify each non-pass": `{"action":"set","id":1,"status":"passed"}`,
})
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
c := newTestConv()
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, false)
if len(*vus) < 2 || !strings.Contains((*vus)[0], "Not done yet") || !strings.Contains((*vus)[1], "Verify each non-pass") {
t.Fatalf("ordre des passes = %q, attendu relance du builder PUIS vérification", *vus)
}
if l := critList(id); len(l) != 1 || l[0].Status != "passed" {
t.Fatalf("critère final : %+v", l)
}
}
// Tour terminé sur une question à l'utilisateur : aucune passe ne part avant
// sa réponse.
func TestPasDeVerificationApresUneQuestion(t *testing.T) {
withWorkspace(t)
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
port, vus := moteurScripte(t, nil)
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
c := newTestConv()
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, true)
if len(*vus) != 0 {
t.Fatalf("%d requête(s) au moteur alors qu'une question attend l'utilisateur", len(*vus))
}
}
// Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien #2.
func TestCritereIdEnChaine(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
toolCriteria(map[string]any{"action": "add", "texts": []any{"a", "b"}}, false)
if out := toolCriteria(map[string]any{"action": "set", "id": "#2", "status": "completed"}, false); !strings.HasPrefix(out, "[ok]") {
t.Fatalf("id en chaîne refusé : %s", out)
}
if l := critList(id); l[1].Status != "completed" || l[0].Status != "pending" {
t.Fatalf("mauvais critère modifié : %+v", l)
}
}
+70
View File
@@ -0,0 +1,70 @@
package loki
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
)
// fauxMoteurOutil : un llama-server de comédie qui, à la PREMIÈRE requête,
// répond par un appel d'outil (name/args), puis par un texte à la suivante.
func fauxMoteurOutil(t *testing.T, name, args string) (port string, requêtes *int32) {
t.Helper()
var n int32
esc := strings.ReplaceAll(args, `"`, `\"`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if atomic.AddInt32(&n, 1) == 1 {
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"` + name + `","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
} else {
_, _ = w.Write([]byte(sseChunk("Verdict enregistré.")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
}
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
return u.Port(), &n
}
// La passe de vérification (Role=verifier) doit pouvoir marquer un critère
// passed via l'outil criteria — c'est tout son rôle. Vécu : « en mode code,
// le système de vérification ne coche plus les critères ».
func TestPasseDeVerificationCocheLesCriteres(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
port, n := fauxMoteurOutil(t, "criteria", `{"action":"set","id":1,"status":"passed"}`)
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
caps := Caps{Agent: true, Code: true, Role: "verifier"}
var tools []string
_, err := runChat(context.Background(), []Message{
{Role: "system", Content: rolePrompt("verifier")},
{Role: "user", Content: "Verify."},
}, 0.2, caps, func(ev StreamEvent) bool {
if ev.ToolUsed != nil && ev.ToolUsed.Done {
tools = append(tools, ev.ToolUsed.Name+" → "+ev.ToolUsed.Result)
}
return true
})
if err != nil {
t.Fatal(err)
}
if *n < 2 {
t.Fatalf("%d requête(s) au moteur : l'appel d'outil n'a pas été suivi d'un second tour", *n)
}
list := critList(id)
if len(list) != 1 || list[0].Status != "passed" {
t.Fatalf("critère non coché par le vérificateur : %+v — outils : %v", list, tools)
}
}
+24
View File
@@ -44,6 +44,12 @@ Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une po
// inflating decode numbers — what you measure here is close to what you'll
// see in real chat at the same context length.
func runBench(nPrompt, nPredict int) (*benchResult, error) {
// Un bench mesure le moteur LOCAL : sur un preset externe, healthCheck dit
// « prêt » sans moteur, et la mesure taperait un port arrêté ou un moteur
// resté en vie — attribuée à tort à ce preset.
if externalActive() {
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
}
port := LLMPort()
if !healthCheck() {
return nil, fmt.Errorf("serveur injoignable sur :%d", port)
@@ -158,6 +164,24 @@ func loadLastBench() *savedBench {
return &sb
}
// benchMatchesPreset : le bench a-t-il été mesuré sur le modèle ACTUEL du
// preset ? Les benchs sont rangés par id de preset : sans ce contrôle, un
// preset dont le modèle a changé affichait les mesures d'un autre modèle.
// Repris d'AJEAN 0.16.3.
func benchMatchesPreset(sb savedBench, cfg map[string]string) bool {
m := strings.TrimSpace(cfg["MODEL"])
return m != "" && sb.Model == filepath.Base(m)
}
// deletePresetBench oublie le bench d'un preset supprimé.
func deletePresetBench(id string) {
m := loadBenchStore()
if _, ok := m[id]; ok {
delete(m, id)
_ = putJSON(bkState, "bench_presets", m)
}
}
// loadBenchStore renvoie les benchmarks par preset (vide s'il n'y en a pas).
func loadBenchStore() map[string]savedBench {
m := map[string]savedBench{}
+17
View File
@@ -0,0 +1,17 @@
package loki
import "testing"
// Un bench n'est affiché que pour le modèle sur lequel il a été mesuré.
func TestBenchMatchesPreset(t *testing.T) {
sb := savedBench{Model: "Qwen3-27B-Q4_K_M.gguf"}
if !benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Qwen3-27B-Q4_K_M.gguf"}) {
t.Fatal("même modèle : le bench doit s'afficher")
}
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Autre.gguf"}) {
t.Fatal("autre modèle : le bench ne doit pas s'afficher")
}
if benchMatchesPreset(sb, map[string]string{}) {
t.Fatal("preset sans modèle : pas de bench")
}
}
+3
View File
@@ -89,3 +89,6 @@ 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)
}
// codeBudgetFactor : multiplicateur du palier en mode Code (voir runChat).
const codeBudgetFactor = 3
+423 -79
View File
@@ -28,6 +28,9 @@ type Message struct {
}
type ToolCall struct {
// Index : position de l'appel dans un flux (delta OpenAI). Absent des
// messages de l'historique (nil → omis).
Index *int `json:"index,omitempty"`
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFunc `json:"function"`
@@ -96,9 +99,9 @@ func memAddTool() Tool {
Description: "Create a memory page. One topic per page, kebab-case name, first line = title (#). Refuses to overwrite an existing page (use mem_edit).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Page name"},
"content": map[string]any{"type": "string", "description": "Markdown, first line = title #"},
"properties": orderedProps{
{"file", map[string]any{"type": "string", "description": "Page name"}},
{"content", map[string]any{"type": "string", "description": "Markdown, first line = title #"}},
},
"required": []string{"file", "content"},
},
@@ -114,10 +117,10 @@ func memEditTool() Tool {
Description: "Patch a memory page: old → new, old unique in the page. To append, put the current end of the page in old and the extended version in new.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Page name"},
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"},
"new": map[string]any{"type": "string", "description": "Replacement"},
"properties": orderedProps{
{"file", map[string]any{"type": "string", "description": "Page name"}},
{"old", map[string]any{"type": "string", "description": "Exact text to replace (unique)"}},
{"new", map[string]any{"type": "string", "description": "Replacement"}},
},
"required": []string{"file", "old", "new"},
},
@@ -133,10 +136,11 @@ func editTool() Tool {
Description: "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Path"},
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"},
"new": map[string]any{"type": "string", "description": "Replacement"},
// Ordre voulu : le chemin d'abord (voir orderedProps).
"properties": orderedProps{
{"file", map[string]any{"type": "string", "description": "Path"}},
{"old", map[string]any{"type": "string", "description": "Exact text to replace (unique)"}},
{"new", map[string]any{"type": "string", "description": "Replacement"}},
},
"required": []string{"file", "old", "new"},
},
@@ -152,9 +156,10 @@ func writeTool() Tool {
Description: "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Path"},
"content": map[string]any{"type": "string", "description": "Full content"},
// Ordre voulu : le chemin d'abord (voir orderedProps).
"properties": orderedProps{
{"file", map[string]any{"type": "string", "description": "Path"}},
{"content", map[string]any{"type": "string", "description": "Full content"}},
},
"required": []string{"file", "content"},
},
@@ -244,7 +249,10 @@ func InjectSkills(msgs []Message, caps Caps) []Message {
return msgs
}
prefix := strings.Join(parts, "\n\n")
if len(msgs) > 0 && msgs[0].Role == "system" {
// Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le
// préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait
// plus le sortir du bloc système commun (voir isProjectSystem).
if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) {
existing, _ := msgs[0].Content.(string)
merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
return merged
@@ -287,15 +295,29 @@ func steerSystem(msgs []Message, hint string) []Message {
// 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).
//
// Ce qui dépend du PROJET (description, index mémoire, trackers) ne va PAS dans
// ce bloc mais en tête du premier message utilisateur (AJEAN 0.15.8). Sur un
// modèle hybride (Qwen3.5+, couches récurrentes), llama.cpp ne sait reprendre un
// prompt qu'à un point de sauvegarde, et il n'en pose qu'au DÉBUT des messages
// utilisateur. Laissé dans le système, le contexte projet faisait diverger le
// prompt au milieu du bloc : changer de projet recalculait tout. Au début du 1er
// message user, la divergence tombe pile sur un point de sauvegarde, après le
// système commun, qui reste en cache.
func normalizeSystemMessages(msgs []Message) []Message {
var sys []string
var sys, proj []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) != "" {
if strings.TrimSpace(s) == "" {
continue
}
if isProjectSystem(m) {
proj = append(proj, s)
} else {
sys = append(sys, s)
}
continue
@@ -310,6 +332,12 @@ func normalizeSystemMessages(msgs []Message) []Message {
if !sawSystem {
return msgs
}
if len(proj) > 0 {
if !prependToFirstUser(rest, strings.Join(proj, "\n\n")) {
// Pas encore de message user (cas théorique) : on les garde en système.
sys = append(sys, proj...)
}
}
// 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 {
@@ -320,6 +348,48 @@ func normalizeSystemMessages(msgs []Message) []Message {
return append(out, rest...)
}
// isProjectSystem : message système propre au projet actif (description, index
// mémoire, trackers) ou à la conversation (rappel des pages mémoire lues, posé
// au compactage), à sortir du bloc système commun (voir
// normalizeSystemMessages) : ce bloc doit rester identique partout pour rester
// en cache.
func isProjectSystem(m Message) bool {
s, ok := m.Content.(string)
if !ok || m.Role != "system" {
return false
}
return strings.HasPrefix(s, projectContextPrefix) ||
strings.HasPrefix(s, memIndexPrefix) ||
strings.HasPrefix(s, trackerIndexPrefix) ||
strings.HasPrefix(s, memReminderPrefix) ||
strings.HasPrefix(s, codeInstructionsPrefix)
}
// prependToFirstUser place ctx en tête du premier message user de msgs (modifié
// en place : msgs est déjà une copie, mais ses Content ne sont PAS recopiés —
// on remplace donc la valeur, jamais on ne mute une tranche partagée).
// Renvoie false s'il n'y a aucun message user.
func prependToFirstUser(msgs []Message, ctx string) bool {
block := "<project_context>\n" + ctx + "\n</project_context>\n\n"
for i, m := range msgs {
if m.Role != "user" {
continue
}
switch c := m.Content.(type) {
case string:
msgs[i].Content = block + c
case []any:
msgs[i].Content = append([]any{map[string]any{"type": "text", "text": block}}, c...)
case []map[string]any:
msgs[i].Content = append([]map[string]any{{"type": "text", "text": block}}, c...)
default:
return false
}
return true
}
return false
}
// EnabledTools returns the tools to advertise on the next inference call.
func EnabledTools(caps Caps) []Tool {
tools := []Tool{}
@@ -474,6 +544,9 @@ 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
// Added / Removed : nombre RÉEL de lignes ajoutées / retirées. Diff est
// tronqué pour l'affichage, on ne peut donc pas recompter à partir de lui.
Added, Removed int
// 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
@@ -481,26 +554,22 @@ type ToolUsedEvent struct {
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
Image string
// ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle, qui affichait
// sinon la taille de l'aperçu — toujours le même chiffre, quelle que soit la
// page lue.
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle.
ResultChars int
// ResultID : identifiant (tool_call_id) avec lequel l'UI CHARGE le résultat
// complet à la demande via /api/chat/tool-result. Le flux ne transporte que
// l'aperçu ; le reste ne descend qu'au clic sur « voir plus ». Vide = Result
// est déjà complet (rien à charger).
// ResultID : identifiant (voir saveToolResult) permettant à l'UI de CHARGER
// le résultat complet à la demande via /api/chat/tool-result — le flux ne
// transporte que l'aperçu. Vide = Result est déjà complet.
ResultID string
}
// toolPreviewChars borne l'aperçu de résultat d'outil envoyé à l'UI. Le résultat
// COMPLET reste côté serveur (conv.Messages) et se charge à la demande.
//
// Avant, l'UI recevait tout le résultat tronqué à 12000 caractères : chaque tour
// poussait des dizaines de kilo-octets dans le flux SSE et dans le journal
// persisté, pour un bloc que personne ne déplie une fois sur dix.
// toolPreviewChars borne l'aperçu de résultat d'outil envoyé à l'UI (AJEAN
// 0.15.1). Le flux — et son rejeu au rechargement — ne transporte plus que
// ça ; le reste se charge au clic sur « voir plus » (tool_results.go).
// Avant, chaque résultat partait en entier (jusqu'à 12 000 caractères), et un
// long fil d'agent en rejouait des centaines à chaque ouverture.
const toolPreviewChars = 1600
// toolResultPreview renvoie (aperçu, taille réelle en runes, coupé ?).
// toolResultPreview renvoie (aperçu, taille réelle en runes, coupé?).
func toolResultPreview(s string) (string, int, bool) {
r := []rune(s)
if len(r) <= toolPreviewChars {
@@ -510,18 +579,18 @@ func toolResultPreview(s string) (string, int, bool) {
}
// fillToolResult pose sur l'événement l'aperçu envoyé à l'UI, la taille réelle
// (pour le compteur ~N tok) et, si le résultat est coupé et qu'on a un id, la
// référence pour charger le reste à la demande. Sans id utilisable, on envoie le
// résultat entier : mieux vaut un flux plus lourd qu'un bloc qu'on ne peut plus
// déplier.
func fillToolResult(ev *ToolUsedEvent, result, tcID string) *ToolUsedEvent {
// et, si le résultat est coupé, l'id qui permet de charger le reste. Si
// l'enregistrement échoue, on envoie le résultat entier (il reste borné par les
// plafonds propres à chaque outil).
func fillToolResult(ev *ToolUsedEvent, result string) *ToolUsedEvent {
prev, n, cut := toolResultPreview(result)
ev.ResultChars = n
if cut && tcID != "" {
ev.Result = prev
ev.ResultID = tcID
} else {
ev.Result = result
ev.Result = result
if cut {
if id := saveToolResult(result); id != "" {
ev.Result = prev
ev.ResultID = id
}
}
return ev
}
@@ -596,22 +665,30 @@ func writeBodyKey(tool string) string {
// streaming tool-call arguments JSON, so the UI can show the command being
// typed live. Best-effort: it tolerates a truncated tail and basic escapes.
func previewArg(args, key string) string {
v, _ := previewArgDone(args, key)
return v
}
// previewArgDone : comme previewArg, et dit si la valeur est COMPLÈTE (guillemet
// fermant reçu) — de quoi agir sur un argument avant la fin du flux.
func previewArgDone(args, key string) (string, bool) {
i := strings.Index(args, "\""+key+"\"")
if i < 0 {
return ""
return "", false
}
rest := args[i+len(key)+2:]
if j := strings.Index(rest, ":"); j >= 0 {
rest = rest[j+1:]
} else {
return ""
return "", false
}
q := strings.Index(rest, "\"")
if q < 0 {
return ""
return "", false
}
rest = rest[q+1:]
var b strings.Builder
closed := false
for x := 0; x < len(rest); x++ {
c := rest[x]
if c == '\\' && x+1 < len(rest) {
@@ -632,11 +709,12 @@ func previewArg(args, key string) string {
continue
}
if c == '"' {
closed = true
break
}
b.WriteByte(c)
}
return b.String()
return b.String(), closed
}
// StatsEvent carries llama.cpp's per-completion timing (final chunk).
@@ -809,7 +887,12 @@ func toolCallLabel(name string, args map[string]any) string {
return label
}
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) {
// injectQueued (optionnel, variadique pour ne pas toucher aux appels hors chat)
// est consulté à CHAQUE frontière d'étape de la boucle d'outils : il renvoie les
// messages utilisateur mis en file PENDANT la génération, pour que le modèle les
// prenne en compte dans la SUITE de sa réponse (AJEAN 0.14.0) au lieu
// d'obliger à arrêter puis relancer. Ajoutés à `messages` ET à `extra`.
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
var extra []Message
tools := EnabledTools(caps)
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
@@ -843,6 +926,8 @@ 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
// Réductions forcées (shrinkToFit) quand le compactage n'a pas suffi.
shrinkRetries := 0
// Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par
// tour, comme les autres filets.
effortRetried := false
@@ -863,13 +948,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// 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
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
patternRetries := 0
// 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
// Reprises après un flux coupé EN COURS de réponse (API externe seulement,
// voir après la lecture du flux). Remis à zéro à chaque complétion lue en
// entier.
const maxStreamRetries = 5
streamRetries := 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
@@ -881,7 +971,27 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// 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()
// Mode Code : lire, éditer, compiler, relancer les tests — un vrai tour de
// build dépasse vite 24 appels sans tourner en rond, et le troisième rappel
// (« n'appelle plus d'outil ») coupait le travail au milieu. Palier triplé.
if caps.Code {
budget *= codeBudgetFactor
}
for iter := 0; ; iter++ {
// Ajout en cours de réponse : entre deux étapes (après un appel d'outil ou
// une relance), on injecte les messages mis en file par l'utilisateur. Pas
// à iter==0 : le message initial du tour est déjà dans `messages`. Ni après
// un stop : la boucle repasse ici une fois l'outil interrompu, et le
// message en file serait journalisé alors que l'utilisateur a repris la
// main (dropQueued l'abandonne à la fin du tour).
if iter > 0 && ctx.Err() == nil {
for _, inject := range injectQueued {
if q := inject(); len(q) > 0 {
messages = append(messages, q...)
extra = append(extra, q...)
}
}
}
// 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 != "" {
@@ -893,7 +1003,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
"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),
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(normalizeSystemMessages(messages)),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
@@ -910,7 +1022,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if reasoningEffort != "" {
payload["reasoning_effort"] = reasoningEffort
}
if reasoningKwargs != nil {
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
// distante stricte (OpenAI) refuse un argument inconnu (400).
if reasoningKwargs != nil && !ep.External {
payload["chat_template_kwargs"] = reasoningKwargs
}
if len(tools) > 0 && !disableTools {
@@ -931,6 +1045,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// 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)
tReq := time.Now() // secours des stats quand le serveur n'envoie pas de timings
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Rien n'est encore parti à l'écran : le tour est rejouable tel quel.
@@ -978,7 +1093,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// 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.
if compactEnabled() && !compactedRetry {
// ⚠️ Seulement si l'erreur est VRAIMENT un débordement de contexte : avant,
// n'importe quel refus (appel d'outil mal formé, modèle en chargement,
// erreur de template…) résumait ~75 % de la conversation, même courte.
if compactEnabled() && !compactedRetry && contextOverflow(msg, messages) {
if c, changed := compactMessages(ctx, messages, caps); changed {
compactedRetry = true
// ⚠️ Journaliser AVANT d'installer le résultat : l'ancien ordre
@@ -994,15 +1112,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
continue
}
}
// Compactage impuissant (ou déjà tenté et encore trop long) : réduction
// forcée plutôt que de laisser remonter un 400 qui bloque la
// conversation (AJEAN 0.17.5).
if compactEnabled() && shrinkRetries < 2 && contextOverflow(msg, messages) {
if c, changed := shrinkToFit(messages, overflowTokens(msg)); changed {
shrinkRetries++
logCompact("réduction", overflowTokens(msg), messages, c, changed)
messages = c
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
continue
}
}
// Most common 500 here: llama.cpp couldn't parse a malformed tool call
// the model emitted. Retry the turn once without tools so it answers
// in plain text rather than leaving the chat dead.
if !disableTools && len(tools) > 0 {
// in plain text rather than leaving the chat dead. Seulement sur 500 :
// un 401, 429 ou 502 (API externe, passerelle) n'a rien à voir avec un
// appel mal formé, et couper les outils pour ça cassait le tour d'agent.
if resp.StatusCode == http.StatusInternalServerError && !disableTools && len(tools) > 0 {
disableTools = true
// Nudge the model to answer in plain text from what it already
// gathered, so it doesn't immediately re-emit a tool call that
// llama.cpp would again fail to parse.
messages = steerSystem(messages, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.")
messages = steerSystem(messages, "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.")
continue
}
// Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut
@@ -1036,8 +1169,36 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ
// pour que les consommateurs (terminal, web) aient toujours tout.
var stats StatsEvent
// Serveur sans `timings` (API tierces) : on mesure nous-mêmes le décodage
// entre le premier et le dernier token (voir après la boucle).
sawTimings := false
var tFirst, tLast time.Time
usageGen, genChunks := 0, 0
lastPreview := "" // last command preview emitted (to stream the typing)
lastBodyLines := -1 // lignes déjà diffusées du corps en cours d'écriture
// sentAnswer : du texte de réponse ou un outil est déjà parti vers l'UI pour
// cette complétion (une reprise le doublerait). sentReasoning : seul du
// raisonnement est parti, qu'on sait retirer (DropReasoning). shown : texte
// de réponse réellement affiché, rendu au modèle pour qu'il reprenne après
// une coupure. typingTool : appel d'outil en cours d'écriture à l'écran.
sentAnswer, sentReasoning := false, false
var shown strings.Builder
var typingTool *ToolUsedEvent
scb := func(ev StreamEvent) bool {
if ev.Content != "" {
sentAnswer = true
shown.WriteString(ev.Content)
}
if ev.ToolUsed != nil {
sentAnswer = true
t := *ev.ToolUsed
typingTool = &t
}
if ev.Reasoning != "" {
sentReasoning = true
}
return cb(ev)
}
// Per-completion reasoning-split state (see reasoningOn comment above).
sawReasoningField := false
thinkOpen := reasoningOn
@@ -1049,6 +1210,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
aborted := false
// patternHit : flux coupé sur un appel d'outil écrit en texte (voir plus bas).
patternHit := false
// preflight : write/edit refusé dès son chemin (voir plus bas) ; non-nil
// mais vide = chemin déjà vérifié, rien à signaler.
var preflight *preflightRefusal
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
@@ -1067,6 +1233,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// garde de choices, sinon `gen_tokens`/`gen_per_second` (decode) sont jetés
// et l'UI retombe à « 0 tok/s » à la fin de la génération.
if chunk.Timings != nil {
sawTimings = true
stats.PromptTokens = chunk.Timings.PromptN
stats.PromptPerSecond = chunk.Timings.PromptPerSecond
stats.PromptMs = chunk.Timings.PromptMs
@@ -1074,21 +1241,37 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
stats.GenPerSecond = chunk.Timings.PredictedPerSec
stats.GenMs = chunk.Timings.PredictedMs
s := stats
cb(StreamEvent{Stats: &s})
scb(StreamEvent{Stats: &s})
}
if chunk.Usage != nil && chunk.Usage.CompletionTokens > 0 {
usageGen = chunk.Usage.CompletionTokens
}
if chunk.Usage != nil && chunk.Usage.PromptTokens > 0 {
stats.PromptTokensTotal = chunk.Usage.PromptTokens
s := stats
cb(StreamEvent{Stats: &s})
scb(StreamEvent{Stats: &s})
}
if len(chunk.Choices) == 0 {
continue
}
ch := chunk.Choices[0]
if ch.Delta.Content != "" || ch.Delta.ReasoningContent != "" || len(ch.Delta.ToolCalls) > 0 {
now := time.Now()
if tFirst.IsZero() {
tFirst = now
}
tLast = now
genChunks++
}
if ch.FinishReason != "" {
finishReason = ch.FinishReason
}
if len(ch.Delta.ToolCalls) > 0 {
// Un tool_call n'est retenu que si des outils ont VRAIMENT été annoncés ce
// tour-ci (même condition que le payload). Sinon — mode agent coupé, ou
// relance sans outils après un 500 — le moteur peut quand même parser un
// appel que le modèle a émis de lui-même ; l'exécuter donnait « outil
// inconnu », réinjecté puis mis en boucle. On l'ignore (AJEAN 0.13.12).
if len(tools) > 0 && !disableTools && len(ch.Delta.ToolCalls) > 0 {
// Un appel d'outil clôt le texte : on vide MAINTENANT le reliquat
// retenu par la garde « </think> » (voir plus bas). Sinon il n'était
// émis qu'en fin de flux, donc APRÈS l'événement d'outil, et l'UI
@@ -1098,14 +1281,21 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if thinkOpen && thinkTail.Len() > 0 {
tail := thinkTail.String()
thinkTail.Reset()
if !cb(StreamEvent{Content: tail}) {
if !scb(StreamEvent{Content: tail}) {
aborted = true
break
}
}
for i, tc := range ch.Delta.ToolCalls {
// llama.cpp's stream may omit index; fall back to slot i.
// Le champ index dit à quel appel appartient ce morceau. Un serveur
// qui envoie un morceau par chunk met l'appel n°2 en position 0 du
// chunk : se fier à i fusionnait les appels parallèles (noms
// écrasés, JSON collés « {…}{…} »). Sans index (certains builds
// llama.cpp), on retombe sur la position dans le chunk.
idx := i
if tc.Index != nil {
idx = *tc.Index
}
cur, ok := toolCalls[idx]
if !ok {
cur = &ToolCall{Type: "function"}
@@ -1161,11 +1351,25 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if grew {
lastBodyLines = strings.Count(body, "\n")
}
if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) {
if !scb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) {
aborted = true
break
}
}
// Pré-vol (OpenFox 2.0.154) : dès que le chemin d'un write/edit est
// complet, on vérifie les gardes du mode Code (fichier lu avant
// d'être modifié, chemin permis). Refusé : on coupe la génération
// MAINTENANT au lieu de laisser le modèle écrire tout un fichier
// qui serait de toute façon rejeté.
if caps.Code && preflight == nil && (cur.Function.Name == "write" || cur.Function.Name == "edit") {
if file, done := previewArgDone(cur.Function.Arguments, "file"); done && strings.TrimSpace(file) != "" {
if msg := codeWriteGuard(caps, file, cur.Function.Name == "write"); msg != "" {
preflight = &preflightRefusal{name: cur.Function.Name, id: cur.ID, file: file, msg: msg}
break
}
preflight = &preflightRefusal{} // vérifié : plus besoin de regarder
}
}
}
continue
}
@@ -1173,7 +1377,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Backend already separates reasoning — trust it, disable our split.
sawReasoningField = true
thinkOpen = false
if !cb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
if !scb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
aborted = true
break
}
@@ -1181,10 +1385,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if ch.Delta.Content != "" {
assistantContent.WriteString(ch.Delta.Content)
if !thinkOpen || sawReasoningField {
if !cb(StreamEvent{Content: ch.Delta.Content}) {
if !scb(StreamEvent{Content: ch.Delta.Content}) {
aborted = true
break
}
// Appel d'outil écrit en texte, repéré PENDANT le flux (OpenFox) :
// on coupe tout de suite au lieu de laisser le modèle dérouler un
// faux appel — parfois un fichier entier — qui ne sera jamais
// exécuté. La relance corrective part juste après (voir fin de
// boucle). Testé seulement quand le morceau peut ouvrir un motif.
if len(tools) > 0 && !disableTools && patternRetries < maxPatternRetries &&
strings.ContainsAny(ch.Delta.Content, "<`{[_.") && textualToolCall(assistantContent.String()) {
patternHit = true
break
}
} else {
// The prompt opened a <think> block. Stream `content` LIVE as
// the answer, holding back only a short tail that could be the
@@ -1205,11 +1419,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n")
thinkOpen = false
thinkTail.Reset()
if reason != "" && !cb(StreamEvent{Reasoning: reason}) {
if reason != "" && !scb(StreamEvent{Reasoning: reason}) {
aborted = true
break
}
if after != "" && !cb(StreamEvent{Content: after}) {
if after != "" && !scb(StreamEvent{Content: after}) {
aborted = true
break
}
@@ -1226,7 +1440,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
emit := s[:cut]
thinkTail.Reset()
thinkTail.WriteString(s[cut:])
if !cb(StreamEvent{Content: emit}) {
if !scb(StreamEvent{Content: emit}) {
aborted = true
break
}
@@ -1237,7 +1451,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
// Flush the held-back tail (never part of a </think>): it's answer text.
if !aborted && thinkOpen && thinkTail.Len() > 0 {
cb(StreamEvent{Content: strings.TrimLeft(thinkTail.String(), "\r\n")})
scb(StreamEvent{Content: strings.TrimLeft(thinkTail.String(), "\r\n")})
}
// ⚠️ Le flux a-t-il fini, ou CASSÉ ? sc.Scan() renvoie false dans les deux
// cas, et l'erreur n'était jamais consultée : une lecture coupée en plein
@@ -1250,14 +1464,96 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// arguments tronqués. On refuse donc le tour, en le disant.
scanErr := sc.Err()
resp.Body.Close()
if !sawTimings && !tFirst.IsZero() {
// Décodage mesuré ici ; le 1er token tombe dans la lecture du prompt.
// Pas de débit de lecture : le serveur a pu réutiliser une partie du
// prompt en cache, et total/délai donnerait des chiffres fantaisistes.
gen := usageGen
if gen == 0 {
gen = genChunks // sans usage : un chunk ≈ un token
}
stats.PromptMs = float64(tFirst.Sub(tReq).Milliseconds())
stats.GenTokens = gen
stats.GenMs = float64(tLast.Sub(tFirst).Milliseconds())
if sec := tLast.Sub(tFirst).Seconds(); sec > 0 && gen > 1 {
stats.GenPerSecond = float64(gen-1) / sec
}
s := stats
cb(StreamEvent{Stats: &s})
}
if aborted {
return extra, nil
}
// Coupure APRÈS le dernier chunk (finish_reason reçu) : la réponse est
// complète, seule la fermeture a raté. On la garde telle quelle. Flux
// coupé par NOUS sur un appel écrit en texte : pas une panne non plus.
if scanErr != nil && (finishReason != "" || patternHit || (preflight != nil && preflight.msg != "")) {
scanErr = nil
}
// Flux coupé vers une API DISTANTE (Wi-Fi, VPN, proxy qui décroche) : on
// relance au lieu d'abandonner le tour (AJEAN 0.17.4). Jamais pour le
// llama-server local : coupé, il a planté, et rejouer ne ferait que
// retarder le message d'erreur.
// - Rien de la réponse n'est encore affiché : on rejoue la même requête
// (le raisonnement déjà montré est retiré, il va être régénéré).
// - Du texte est déjà affiché : on le rend au modèle comme début de sa
// réponse, suivi d'un « connexion coupée, continue », et il reprend où
// il s'était arrêté. Ces deux messages ne vont que dans la vue modèle
// de ce tour : la réponse persistée est le texte complet, reconstitué
// par l'appelant à partir du flux. Un appel d'outil à moitié écrit est
// jeté : le modèle le réémettra.
if scanErr != nil && ctx.Err() == nil && !errors.Is(scanErr, bufio.ErrTooLong) &&
ep.External && streamRetries < maxStreamRetries {
streamRetries++
if sentReasoning && !sentAnswer {
cb(StreamEvent{DropReasoning: true})
}
// Clôt la bulle de l'outil à moitié écrit : il va être réémis, sinon
// elle resterait « en cours d'écriture » pour toujours.
if typingTool != nil {
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: typingTool.Name, Label: typingTool.Label, Done: true, Result: "(appel interrompu par une coupure réseau, relancé)"}})
}
logStreamRetry(streamRetries, maxStreamRetries, scanErr)
if llmNetBackoff(ctx, streamRetries) == nil {
if partial := shown.String(); strings.TrimSpace(partial) != "" {
messages = append(messages,
Message{Role: "assistant", Content: partial},
Message{Role: "user", Content: "The connection was cut during your answer. Continue exactly where you stopped, without repeating what you already wrote."})
}
continue
}
}
if scanErr != nil && ctx.Err() == nil {
err := streamCutError(scanErr)
cb(StreamEvent{Err: err})
return extra, err
}
// Complétion lue en entier : le budget de reprises vaut par coupure
// rapprochée, pas pour tout un long tour d'agent.
streamRetries = 0
// Écriture refusée en plein flux (pré-vol) : l'appel, réduit à son chemin,
// entre dans l'historique avec le refus pour résultat — le modèle voit
// pourquoi, et la boucle repart pour qu'il lise le fichier d'abord.
if preflight != nil && preflight.msg != "" {
id := preflight.id
if id == "" {
id = fmt.Sprintf("call_%d_0", iter)
}
fileArg, _ := json.Marshal(map[string]string{"file": preflight.file})
tc := ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: preflight.name, Arguments: string(fileArg)}}
assistant := Message{Role: "assistant", ToolCalls: []ToolCall{tc}}
if s := assistantContent.String(); s != "" {
assistant.Content = s
}
result := preflight.msg + " (écriture interrompue avant la fin : rien n'a été modifié)"
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: preflight.name, Label: preflight.file, Done: true, Result: result}})
toolMsg := Message{Role: "tool", ToolCallID: id, Content: result}
messages = append(messages, assistant, toolMsg)
extra = append(extra, assistant, toolMsg)
toolRuns++
continue
}
// Treat any accumulated tool calls as a tool turn even if the backend set
// finish_reason to "stop" instead of "tool_calls" (some llama.cpp builds
@@ -1270,6 +1566,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
sort.Ints(idxs)
tcs := make([]ToolCall, 0, len(idxs))
// Appels dont les arguments sont du JSON CASSÉ (tronqué, mal formé) : on ne
// les exécute pas (voir plus bas). Des arguments vides restent valides :
// c'est la forme normale d'un outil sans paramètre.
badArgs := map[string]bool{}
for i, k := range idxs {
tc := *toolCalls[k]
if tc.ID == "" {
@@ -1284,7 +1584,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// en objet vide (l'appel a de toute façon déjà été exécuté). json.Valid("")
// étant faux, ça couvre aussi le cas vide d'origine.
if !json.Valid([]byte(tc.Function.Arguments)) {
if strings.TrimSpace(tc.Function.Arguments) != "" {
badArgs[tc.ID] = true
}
tc.Function.Arguments = "{}"
} else {
// Nom ou argument appris d'un autre agent (read_file, old_string…) :
// traduit vers l'outil réel, AVANT de ranger l'appel dans
// l'historique — le modèle y relit l'appel tel qu'exécuté.
repairToolCall(&tc, tools)
}
tcs = append(tcs, tc)
}
@@ -1294,6 +1602,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
messages = append(messages, assistant)
extra = append(extra, assistant)
// Début des résultats de CETTE étape, que le moteur n'a pas encore
// comptés (voir le test de compactage en cours de tour, plus bas).
stepStart := len(messages)
// Appel émis par le protocole : le budget de relances « appel écrit
// en texte » repart à neuf.
patternRetries = 0
// 2. Execute each tool locally and append a "tool" reply.
for _, tc := range tcs {
// Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce
@@ -1315,6 +1629,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// 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
var diffAdd, diffDel int // vrais totaux (diff est tronqué pour l'UI)
// 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
@@ -1322,11 +1637,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// rejoue pas. Les petits modèles réémettent volontiers deux fois la
// même écriture ; la rejouer produisait une fausse erreur (« old
// introuvable », puisque le remplacement est déjà fait).
// Arguments illisibles : l'exécuter avec des arguments vides donnait une
// erreur trompeuse (« fichier manquant », « commande vide »). On le dit
// tel quel au modèle, pour qu'il renvoie un appel complet.
if badArgs[tc.ID] {
result = "[erreur] arguments de l'appel illisibles (JSON invalide ou tronqué) : l'outil n'a PAS été exécuté. Renvoie l'appel avec des arguments JSON complets et valides."
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result)})
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg)
extra = append(extra, toolMsg)
continue
}
callKey := tc.Function.Name + "\x00" + tc.Function.Arguments
if prev, seen := doneCalls[callKey]; seen && dedupableTool(tc.Function.Name) {
repeatCount[callKey]++
result = repeatedCallResult(prev, repeatCount[callKey])
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result, tc.ID)})
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result)})
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg)
extra = append(extra, toolMsg)
@@ -1375,7 +1701,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
result = "[erreur] " + werr.Error()
} else {
result = fmt.Sprintf("[ok] page '%s' créée", label)
diff = addedDiff(content)
diff, diffAdd = addedDiff(content)
}
case "mem_edit":
oldText, _ := args["old"].(string)
@@ -1386,7 +1712,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
result = "[erreur] " + werr.Error()
} else {
result = fmt.Sprintf("[ok] page '%s' modifiée", label)
diff = lineDiff(oldText, newText)
diff, diffAdd, diffDel = lineDiff(oldText, newText)
}
case "write":
content, _ := args["content"].(string)
@@ -1399,7 +1725,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
} else {
result = fileWrite(label, content)
if !strings.HasPrefix(result, "[erreur]") {
diff = addedDiff(content)
diff, diffAdd = addedDiff(content)
trackerNoteWrite(resolveAgentPath(label))
result += lspDiagBlock(resolveAgentPath(label), caps)
}
@@ -1415,7 +1741,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
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)
diff, diffAdd, diffDel = lineDiff(oldText, newText)
trackerNoteWrite(resolveAgentPath(label))
result += lspDiagBlock(resolveAgentPath(label), caps)
}
@@ -1437,7 +1763,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case "bash_tail":
result = toolBashTail(args)
case "git_status":
result = toolGitStatus(ctx)
result = toolGitStatus(ctx, args)
case "git_diff":
result = toolGitDiff(ctx, args)
case "git_clone":
@@ -1460,6 +1786,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
result = refusedCommandResult(reason)
break
}
// Mode Code : « cmd & » laisse un process orphelin, sans sortie
// lisible ni moyen de l'arrêter — bash_bg est fait pour ça.
if caps.Code && trailingBackground(label) {
result = "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash."
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
@@ -1520,8 +1852,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if tc.Function.Name == "web_screenshot" {
shot = capturedRelPath(result)
}
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Image: shot}, result, tc.ID)})
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result)})
// Plafond pour le MODÈLE seulement : l'UI vient de recevoir le
// résultat complet (aperçu + « voir plus »).
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)}
messages = append(messages, toolMsg)
extra = append(extra, toolMsg)
// Capture d'écran + vision active : on fait SUIVRE l'image elle-même
@@ -1559,7 +1893,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// enchaînés), on partait à 60% et on finissait en dépassement — rattrapé au
// mieux par le filet réactif sur 500, une seule fois. On re-teste donc ici,
// avec le contexte RÉEL du dernier appel (usage.prompt_tokens + généré).
if used := stats.PromptTokensTotal + stats.GenTokens; compactWouldTrigger(messages, used) {
// + les résultats d'outils de CETTE étape : le moteur ne les a pas encore
// comptés. Sans eux, une étape qui lit plusieurs gros fichiers d'un coup
// passait de 60 % à plus de 130 % de la fenêtre sans jamais compacter.
// Serveur sans usage (base 0) : on laisse 0, compactWouldTrigger estime
// alors TOUT l'historique — étape comprise.
used := 0
if base := stats.PromptTokensTotal + stats.GenTokens; base > 0 {
used = base + estimateTokens(messages[stepStart:])
}
if compactWouldTrigger(messages, used) {
yes, no := true, false
cb(StreamEvent{Compacting: &yes})
c, changed := compactMessages(ctx, messages, caps)
@@ -1581,10 +1924,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// (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
if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" &&
patternRetries < maxPatternRetries && len(tools) > 0 && !disableTools {
patternRetries++
bad := Message{Role: "assistant", Content: assistantContent.String()}
fix := Message{Role: "user", Content: retryCorrective}
fix := Message{Role: "user", Content: retryCorrective(snippet)}
messages = append(messages, bad, fix)
extra = append(extra, bad, fix)
continue
+167
View File
@@ -0,0 +1,167 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
// Flux coupé vers une API EXTERNE après un début de réponse : le tour reprend,
// le modèle reçoit le texte déjà affiché suivi d'un « continue », et la suite
// s'ajoute à l'écran sans doublon ni erreur.
func TestRepriseApresCoupureAPIExterne(t *testing.T) {
testHome(t)
var n int32
var second []Message
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
if atomic.AddInt32(&n, 1) == 1 {
w.WriteHeader(200)
_, _ = w.Write([]byte(sseChunk("Bonjour ")))
w.(http.Flusher).Flush()
conn, _, err := w.(http.Hijacker).Hijack()
if err == nil {
_ = conn.Close()
}
return
}
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
second = body.Messages
w.WriteHeader(200)
_, _ = w.Write([]byte(sseChunk("le monde")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
t.Fatal(err)
}
var content strings.Builder
var gotErr error
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Err != nil {
gotErr = ev.Err
}
content.WriteString(ev.Content)
return true
})
if err != nil || gotErr != nil {
t.Fatalf("coupure externe non reprise : err=%v, ev=%v", err, gotErr)
}
if got := content.String(); got != "Bonjour le monde" {
t.Fatalf("texte affiché = %q", got)
}
if len(second) < 3 || second[len(second)-2].Role != "assistant" || second[len(second)-2].Content != "Bonjour " ||
second[len(second)-1].Role != "user" {
t.Fatalf("la reprise n'a pas rendu le début de réponse au modèle : %+v", second)
}
}
// Deux appels d'outils parallèles livrés chacun en position 0 de son chunk : le
// champ index les sépare. Avant, ils fusionnaient en un seul appel aux
// arguments collés « {…}{…} ».
func TestAppelsParallelesSuiventLIndex(t *testing.T) {
withWorkspace(t)
var n int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if atomic.AddInt32(&n, 1) == 1 {
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","type":"function","function":{"name":"grep","arguments":"{\"pattern\":\"rien\"}"}}]}}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
} else {
_, _ = w.Write([]byte(sseChunk("fini")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
}
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
var done []string
extra, err := runChat(context.Background(), []Message{{Role: "user", Content: "cherche"}}, 0.7, Caps{Agent: true, Code: true}, func(ev StreamEvent) bool {
if ev.ToolUsed != nil && ev.ToolUsed.Done {
done = append(done, ev.ToolUsed.Name)
}
return true
})
if err != nil {
t.Fatal(err)
}
if len(done) != 2 || done[0] != "glob" || done[1] != "grep" {
t.Fatalf("appels exécutés = %v, attendu [glob grep]", done)
}
if len(extra) == 0 || len(extra[0].ToolCalls) != 2 {
t.Fatalf("message assistant : %+v", extra)
}
}
// Serveur sans `timings` (API tierce) : le débit de décodage est mesuré côté
// Loki au lieu de rester à zéro.
func TestDebitMesureSansTimings(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
for _, s := range []string{"un ", "deux ", "trois"} {
_, _ = w.Write([]byte(sseChunk(s)))
w.(http.Flusher).Flush()
time.Sleep(20 * time.Millisecond)
}
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
t.Fatal(err)
}
var last *StatsEvent
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "compte"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Stats != nil {
last = ev.Stats
}
return true
}); err != nil {
t.Fatal(err)
}
if last == nil || last.GenTokens != 3 || last.GenPerSecond <= 0 {
t.Fatalf("stats sans timings = %+v", last)
}
}
// Un refus qui n'a rien d'un appel d'outil mal formé (401 d'une API externe)
// remonte tel quel : avant, n'importe quel statut coupait les outils et
// rejouait le tour, masquant la vraie cause derrière une réponse sans outils.
func TestRefusAPIHors500NeCoupePasLesOutils(t *testing.T) {
testHome(t)
var n int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&n, 1)
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
t.Fatal(err)
}
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("erreur = %v, attendu le 401 de l'API", err)
}
if got := atomic.LoadInt32(&n); got != 1 {
t.Fatalf("%d requêtes : un 401 a été rejoué sans outils", got)
}
}
+12 -1
View File
@@ -43,6 +43,11 @@ const (
llmNetMaxWait = 5 * time.Second
)
// wsaECONNREFUSED : la connexion refusée telle que Windows la rend (WSA 10061).
// syscall.ECONNREFUSED n'y est qu'une valeur inventée par Go, jamais renvoyée
// par le système ; ailleurs, 10061 n'est le numéro d'aucune erreur.
const wsaECONNREFUSED = syscall.Errno(10061)
// 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
@@ -57,7 +62,7 @@ func llmRetryableErr(ctx context.Context, err error) bool {
return false
}
switch {
case errors.Is(err, syscall.ECONNREFUSED): // moteur en cours de démarrage
case errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, wsaECONNREFUSED): // 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
@@ -122,3 +127,9 @@ func llmNetBackoff(ctx context.Context, n int) error {
func logLLMRetry(attempt int, cause string) {
fmt.Fprintf(os.Stderr, "[llm] %s — nouvelle tentative %d/%d\n", cause, attempt, llmNetRetries)
}
// logStreamRetry trace la reprise d'un flux coupé EN COURS de réponse (API
// externe) : budget distinct de celui d'avant le flux, voir runChat.
func logStreamRetry(attempt, max int, cause error) {
fmt.Fprintf(os.Stderr, "[llm] flux coupé en cours de réponse (%v) — reprise %d/%d\n", cause, attempt, max)
}
+26
View File
@@ -22,6 +22,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -145,10 +146,35 @@ func lspDiagnosticsFor(absPath string) string {
if len(diags) == 0 {
return ""
}
// Erreurs d'abord, puis avertissements, puis le reste (OpenFox) : au-delà
// de lspMaxDiags on tronque, et des indices de style passaient devant
// l'erreur de compilation qui comptait. Le compte annonce le total.
rank := func(s int) int {
if s <= 0 {
return 1 // sévérité absente : traitée comme une erreur
}
return s
}
sort.SliceStable(diags, func(i, j int) bool { return rank(diags[i].Severity) < rank(diags[j].Severity) })
nErr, nWarn := 0, 0
for _, d := range diags {
switch rank(d.Severity) {
case 1:
nErr++
case 2:
nWarn++
}
}
total := len(diags)
if len(diags) > lspMaxDiags {
diags = diags[:lspMaxDiags]
}
var b strings.Builder
fmt.Fprintf(&b, "%d erreur(s), %d avertissement(s)", nErr, nWarn)
if total > len(diags) {
fmt.Fprintf(&b, " — %d premiers sur %d", len(diags), total)
}
b.WriteString("\n")
for _, d := range diags {
sev := "info"
switch d.Severity {
+15 -5
View File
@@ -50,6 +50,18 @@ const (
mcpCallTimeout = 120 * time.Second
)
// mcpOutputCap borne la sortie d'un outil MCP renvoyée au modèle. L'amont
// (AJEAN 0.15.2) la transmet désormais EN ENTIER, comme une page mémoire : une
// réponse MCP (un ticket, un document, une requête) coupée à 12 000 caractères
// laissait le modèle travailler sur un début. On s'en rapproche sans renoncer
// à tout garde-fou — sur 65k de contexte, une seule réponse géante noierait la
// fenêtre et déclencherait un compactage en plein raisonnement : le plafond
// suit la fenêtre, ~1 caractère par token de contexte, soit environ un quart
// de celle-ci (≈4 caractères par token). Jamais sous l'ancien plafond.
func mcpOutputCap() int {
return max(12000, ctxWindow())
}
// mcpSession est une connexion vivante à un serveur MCP.
//
// L'entrée est publiée dans le pool AVANT que la connexion aboutisse, pour qu'un
@@ -396,12 +408,10 @@ func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
if err != nil {
return "", err
}
// Le résultat complet part au modèle (même règle que la lecture mémoire) :
// plus de troncature ici. Un serveur MCP est configuré EXPRÈS par le
// propriétaire de la machine pour ce qu'il rend ; le couper au milieu, c'est
// rendre la réponse inutilisable. L'UI, elle, n'affiche qu'un aperçu et
// charge le reste à la demande (voir fillToolResult et /api/chat/tool-result).
out := flattenMCPContent(res)
if r, lim := []rune(out), mcpOutputCap(); len(r) > lim {
out = string(r[:lim]) + fmt.Sprintf("\n…[tronqué : %d caractères au total, %d gardés]", len(r), lim)
}
if res.IsError {
return "[l'outil a renvoyé une erreur]\n" + out, nil
}
+42
View File
@@ -219,3 +219,45 @@ func TestSnapshotRestore(t *testing.T) {
}
}
}
// Activer le chiffrement ne doit pas rendre illisibles les clés-pointeurs du
// bucket des discussions (discussion active, mode Code) ni les critères. Une
// version précédente les chiffrait : la discussion active devenait
// introuvable. Celles déjà chiffrées sont réparées au déverrouillage.
func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
testHome(t)
clearMemDEK()
id := convEnsureActive()
if err := setConvMode(id, "code"); err != nil {
t.Fatal(err)
}
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
if got := getStr(bkChat, ckActive); got != id {
t.Fatalf("discussion active illisible après chiffrement : %q", got)
}
if !convCodeMode() {
t.Fatal("mode Code perdu après chiffrement")
}
if l := critList(id); len(l) != 1 {
t.Fatalf("critères perdus après chiffrement : %+v", l)
}
if !looksEncrypted(getBytes(bkChat, critKey(id))) {
t.Fatal("critères en clair alors que les discussions sont chiffrées")
}
if !bucketFullyEncrypted(bkChat) {
t.Fatal("les clés-pointeurs en clair font croire à un chiffrement incomplet")
}
// Base d'une version précédente : le pointeur a été chiffré.
enc, err := encodeMemContent([]byte(id))
if err != nil {
t.Fatal(err)
}
_ = putBytes(bkChat, ckActive, enc)
healPlainStoreKeys()
if got := getStr(bkChat, ckActive); got != id {
t.Fatalf("pointeur chiffré non réparé : %q", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
package loki
import "testing"
// Chaque projet garde son propre mode mémoire ; un projet sans réglage retombe
// sur l'ancien global MEM_MODE.
func TestMemModePerProject(t *testing.T) {
testHome(t)
ensureDefaultProject()
if err := SetConfigKey("MEM_MODE", "ondemand"); err != nil {
t.Fatal(err)
}
if memMode() != MemOnDemand {
t.Fatalf("sans réglage propre, repli global attendu, got %q", memMode())
}
b, err := createProject("Chantier")
if err != nil {
t.Fatal(err)
}
if err := setActiveProject(b.Slug); err != nil {
t.Fatal(err)
}
if err := setMemMode(MemSearchFirst); err != nil {
t.Fatal(err)
}
if memMode() != MemSearchFirst {
t.Fatalf("mode du projet actif attendu, got %q", memMode())
}
if err := setActiveProject(defaultProjectSlug); err != nil {
t.Fatal(err)
}
if memMode() != MemOnDemand {
t.Fatalf("l'autre projet ne doit pas hériter du réglage, got %q", memMode())
}
if setProjectMemMode(b.Slug, "n'importe quoi") == nil {
t.Fatal("un mode inconnu doit être refusé")
}
}
+44 -4
View File
@@ -12,6 +12,7 @@ package loki
import (
"encoding/json"
"errors"
"strings"
)
// errStoreLocked : écriture chiffrée demandée alors que la DEK n'est pas en RAM.
@@ -90,12 +91,46 @@ func getStoreJSON(bucket, key string, dst any) bool {
return json.Unmarshal(b, dst) == nil
}
// plainStoreKey : clés d'un bucket chiffré qui restent EN CLAIR, parce que le
// code les lit et les écrit avec getStr/putStr — des pointeurs et drapeaux, pas
// des données : la discussion active (son id), le mode Chat|Code d'une
// discussion, la puce « passer en mode Code » déjà montrée. Chiffrées par
// reencryptBucket, elles revenaient à la lecture comme du charabia : la
// discussion active devenait introuvable, le mode Code et ses critères
// disparaissaient dès l'activation du chiffrement.
func plainStoreKey(bucket, key string) bool {
if bucket != bkChat {
return false
}
return key == ckActive || strings.HasPrefix(key, "mode:") || strings.HasPrefix(key, "hinted:")
}
// healPlainStoreKeys remet en clair les clés-pointeurs qu'une version
// précédente avait chiffrées (voir plainStoreKey). À appeler dès que la DEK est
// en RAM, AVANT de recharger la discussion active. Sans DEK, ne fait rien.
func healPlainStoreKeys() {
if !memUnlocked() {
return
}
for _, bucket := range encryptedBuckets {
for k, v := range allKV(bucket) {
raw := []byte(v)
if !plainStoreKey(bucket, k) || !looksEncrypted(raw) {
continue
}
if plain, err := decodeMemContent(raw); err == nil {
_ = putBytes(bucket, k, plain)
}
}
}
}
// reencryptBucket (re)chiffre toutes les valeurs d'un bucket encore en clair,
// avec vérification par relecture. Sûr à rejouer. Exige la DEK en RAM.
func reencryptBucket(bucket string) error {
for k, v := range allKV(bucket) {
raw := []byte(v)
if looksEncrypted(raw) {
if looksEncrypted(raw) || plainStoreKey(bucket, k) {
continue
}
if err := putStoreBytes(bucket, k, raw); err != nil {
@@ -113,7 +148,10 @@ func reencryptBucket(bucket string) error {
// portent le magic). Lisible sans la DEK. Utilisé pour savoir si le chiffrement
// est réellement complet.
func bucketFullyEncrypted(bucket string) bool {
for _, v := range allKV(bucket) {
for k, v := range allKV(bucket) {
if plainStoreKey(bucket, k) {
continue
}
if v != "" && !looksEncrypted([]byte(v)) {
return false
}
@@ -125,16 +163,18 @@ func bucketFullyEncrypted(bucket string) bool {
// chiffrement est actif. Ici : les discussions (bkChat porte l'index, la
// discussion active et le journal de chacune), les blocs archivés au compactage
// (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et
// les trackers (bkTracker, 3ᵉ type de mémoire).
// les trackers (bkTracker, 3ᵉ type de mémoire), et les résultats complets des
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil).
//
// Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils
// portent des réglages, pas des données personnelles — et surtout le coffre
// lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une
// boucle : plus moyen de déverrouiller quoi que ce soit.
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker}
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes}
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
func reencryptChatStores() error {
healPlainStoreKeys()
for _, b := range encryptedBuckets {
if err := reencryptBucket(b); err != nil {
return err
+53
View File
@@ -0,0 +1,53 @@
package loki
import (
"reflect"
"strings"
"testing"
)
// Le contexte PROJET quitte le bloc système pour la tête du 1er message user :
// le système commun reste identique d'un projet à l'autre (cache de prompt), et
// l'historique d'origine n'est pas muté.
func TestNormalizeMovesProjectContextToFirstUser(t *testing.T) {
in := []Message{
{Role: "system", Content: "Préambule commun"},
{Role: "system", Content: projectContextPrefix + " — projet A"},
{Role: "system", Content: memIndexPrefix + " for project A"},
{Role: "user", Content: "bonjour"},
{Role: "assistant", Content: "salut"},
{Role: "user", Content: "suite"},
}
ref := append([]Message(nil), in...)
out := normalizeSystemMessages(in)
if len(out) != 4 || out[0].Content != "Préambule commun" {
t.Fatalf("système commun attendu seul en tête : %#v", out)
}
first, _ := out[1].Content.(string)
if !strings.HasPrefix(first, "<project_context>\n"+projectContextPrefix) ||
!strings.Contains(first, memIndexPrefix) || !strings.HasSuffix(first, "bonjour") {
t.Fatalf("contexte projet absent du 1er message user : %q", first)
}
if out[3].Content != "suite" {
t.Fatal("seul le PREMIER message user reçoit le contexte")
}
if !reflect.DeepEqual(in, ref) {
t.Fatal("l'entrée a été mutée (interdit)")
}
}
// Sans prompt de preset, le premier système est un message projet : le préambule
// ne doit pas l'absorber (il perdrait son préfixe et resterait dans le système).
func TestInjectSkillsKeepsProjectMessageSeparate(t *testing.T) {
in := []Message{
{Role: "system", Content: projectContextPrefix + " — projet A"},
{Role: "user", Content: "bonjour"},
}
out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}))
if s, _ := out[0].Content.(string); out[0].Role != "system" || strings.Contains(s, projectContextPrefix) {
t.Fatalf("le contexte projet ne doit pas rester dans le système : %#v", out[0])
}
if u, _ := out[1].Content.(string); !strings.Contains(u, projectContextPrefix) {
t.Fatalf("contexte projet attendu dans le 1er message user : %#v", out[1])
}
}
+33
View File
@@ -39,6 +39,9 @@ type Project struct {
// de conversation (projectContextMessage) pour qu'elle sache d'emblée sur quoi
// elle travaille, sans avoir à le lui réexpliquer à chaque nouvelle discussion.
Desc string `json:"desc,omitempty"`
// MemMode = mode mémoire PROPRE à ce projet (off/ondemand/always/search).
// Vide = pas encore réglé → repli sur l'ancien global MEM_MODE (voir memMode).
MemMode string `json:"mem_mode,omitempty"`
}
const (
@@ -186,6 +189,36 @@ func projectDesc(slug string) string {
// setProjectDesc enregistre la description d'un projet, tronquée à une taille
// raisonnable : c'est un contexte injecté à CHAQUE conversation, pas un cahier des
// charges. Une chaîne vide efface la description.
// projectMemMode renvoie le mode mémoire propre à un projet ("" si non réglé, ce
// qui déclenche le repli global dans memMode).
func projectMemMode(slug string) string {
for _, p := range listProjects() {
if p.Slug == slug {
return p.MemMode
}
}
return ""
}
// setProjectMemMode enregistre le mode mémoire d'un projet. Une valeur vide
// efface le réglage propre (retour au repli global).
func setProjectMemMode(slug, mode string) error {
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case "", "off", "ondemand", "always", "search":
default:
return fmt.Errorf("mode mémoire invalide: %s", mode)
}
list := listProjects()
for i := range list {
if list[i].Slug == slug {
list[i].MemMode = mode
return saveProjects(list)
}
}
return fmt.Errorf("projet introuvable")
}
func setProjectDesc(slug, desc string) error {
desc = strings.TrimSpace(desc)
const max = 2000
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.12.3"
const Version = "0.14.0"
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
func Main() {
+17
View File
@@ -45,11 +45,22 @@ func loc(tz string) *time.Location {
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
// - expression cron 5 champs.
// - "@once 2026-10-01 16:54" : une seule fois, à cette date et heure murale.
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
s := strings.TrimSpace(schedule)
if s == "" {
return time.Time{}, fmt.Errorf("fréquence vide")
}
if rest, ok := cutPrefix(s, "@once"); ok {
at, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(rest), loc(tz))
if err != nil {
return time.Time{}, fmt.Errorf("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)")
}
if !at.After(from) {
return time.Time{}, fmt.Errorf("%s est déjà passé (il est %s)", at.Format("2006-01-02 15:04"), from.In(loc(tz)).Format("2006-01-02 15:04"))
}
return at, nil
}
if rest, ok := cutPrefix(s, "@every"); ok {
return nextEvery(strings.TrimSpace(rest), tz, from)
}
@@ -249,3 +260,9 @@ func cutPrefix(s, prefix string) (string, bool) {
}
return "", false
}
// isOnce : tâche à exécution unique (« @once … »), désactivée après son passage.
func isOnce(schedule string) bool {
_, ok := cutPrefix(strings.TrimSpace(schedule), "@once")
return ok
}
+68
View File
@@ -0,0 +1,68 @@
package loki
import (
"strings"
"testing"
"time"
)
// « @once » : une date future est acceptée, une date passée refusée.
func TestScheduleOnce(t *testing.T) {
from := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
at, err := nextAfter("@once 2026-10-02 12:30", "UTC", from)
if err != nil || !at.Equal(from.Add(30*time.Minute)) {
t.Fatalf("@once futur : %v, %v", at, err)
}
if _, err := nextAfter("@once 2026-10-02 11:00", "UTC", from); err == nil {
t.Fatal("@once passé accepté")
}
if !isOnce(" @ONCE 2026-10-02 12:30") || isOnce("@every 2h") {
t.Fatal("isOnce se trompe")
}
}
// L'IA crée un rappel « dans 20 minutes » ou « à HH:MM » sans écrire de cron,
// dans le fuseau du navigateur ; une fois passée, la tâche se désactive.
func TestTacheUniqueParLIA(t *testing.T) {
testHome(t)
rememberUserTZ("Europe/Paris")
if got := defaultTaskTZ(); got != "Europe/Paris" {
t.Fatalf("fuseau retenu = %q", got)
}
rememberUserTZ("Pas/Un_Fuseau") // invalide : ignoré
if got := defaultTaskTZ(); got != "Europe/Paris" {
t.Fatalf("fuseau invalide retenu : %q", got)
}
out := toolTaskCreate(map[string]any{"name": "rappel", "prompt": "dis bonjour", "in_minutes": float64(20)})
if !strings.HasPrefix(out, "[ok]") || !strings.Contains(out, "Europe/Paris") {
t.Fatalf("création « dans 20 min » : %s", out)
}
out = toolTaskCreate(map[string]any{"name": "apéro", "prompt": "rappelle l'apéro", "at": "18:30"})
if !strings.HasPrefix(out, "[ok]") {
t.Fatalf("création « à 18:30 » : %s", out)
}
if out := toolTaskCreate(map[string]any{"name": "flou", "prompt": "x", "at": "demain"}); !strings.HasPrefix(out, "[erreur]") {
t.Fatalf("'at' invalide accepté : %s", out)
}
var once Task
for _, tk := range listTasks() {
if tk.Name == "rappel" {
once = tk
}
}
if !isOnce(once.Schedule) || once.TZ != "Europe/Paris" || once.NextRun == 0 {
t.Fatalf("tâche unique mal posée : %+v", once)
}
want := time.Now().Add(20 * time.Minute)
if d := time.UnixMilli(once.NextRun).Sub(want); d > time.Minute || d < -time.Minute {
t.Fatalf("prochaine exécution à %v, attendu ≈ %v", time.UnixMilli(once.NextRun), want)
}
recordTaskEnd(once.ID, time.Now(), "bonjour", nil)
after, _ := getTask(once.ID)
if after.Enabled || after.NextRun != 0 {
t.Fatalf("tâche unique encore active après son passage : %+v", after)
}
}
+14 -12
View File
@@ -68,26 +68,25 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
if temperature == 0 {
temperature = 0.7
}
// La note de tâche (conscience du mode autonome + compte-rendu du passage
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : le
// préfixe système + outils reste ainsi identique à celui du chat, et le cache
// de prompt de llama-server sert aux deux. Une note dans le système faisait
// tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s
// mesurées en amont pour un simple « salut », AJEAN 0.17.5).
if note := taskContextNote(taskName, lastReport); note != "" {
prompt = note + "\n\n" + prompt
}
msgs := []Message{{Role: "user", Content: prompt}}
// Même préambule que la vraie génération : consigne personnelle + préambule
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
// contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de
// contexte (conscience du mode autonome + mémoire du passage précédent),
// fusionnée dans UN SEUL message système en tête — comme l'exigent les
// gabarits stricts (normalizeSystemMessages le garantit de toute façon).
// contexte et les mêmes outils qu'en chat.
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
// chantier — la tâche voit la mémoire et les trackers qu'elle vise.
final := append(projectSystemMessages(), msgs...)
sys := readSysPrompt()
note := taskContextNote(taskName, lastReport)
switch {
case sys != "" && note != "":
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, final...)
case sys != "":
if sys := readSysPrompt(); sys != "" {
final = append([]Message{{Role: "system", Content: sys}}, final...)
case note != "":
final = append([]Message{{Role: "system", Content: note}}, final...)
}
var content strings.Builder
@@ -267,6 +266,9 @@ func recordTaskEnd(id string, start time.Time, report string, err error) {
cur.LastDurMs = now.Sub(start).Milliseconds()
cur.LastRun = now.UnixMilli()
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
if isOnce(cur.Schedule) {
cur.Enabled, cur.NextRun = false, 0 // passée une fois : terminée
}
if errors.Is(err, context.Canceled) {
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
// « interrompue » sans allumer l'indicateur rouge d'erreur.
+60 -10
View File
@@ -54,10 +54,12 @@ func taskCreateTool() Tool {
"script": map[string]any{"type": "string", "description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved"},
// Le format du schedule est le seul point que le modèle ne peut pas
// deviner : il vit ici, dans le paramètre, pas dans la phrase de l'outil.
"schedule": map[string]any{"type": "string", "description": "\"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\""},
"enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"},
"schedule": map[string]any{"type": "string", "description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)"},
"in_minutes": map[string]any{"type": "integer", "description": "One-time task, N minutes from now"},
"at": map[string]any{"type": "string", "description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\""},
"enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"},
},
"required": []string{"name", "schedule"},
"required": []string{"name"},
},
},
}
@@ -101,11 +103,30 @@ func taskDeleteTool() Tool {
}
}
// defaultTaskTZ : le modèle n'a pas le fuseau du navigateur. On reprend celui
// d'une tâche existante (posé par l'UI depuis le navigateur) pour que « tous les
// jours à 9h » tombe à la bonne heure ; à défaut, fuseau vide = heure locale du
// serveur (souvent UTC), ce que la description du planificateur assume déjà.
// stUserTZKey : fuseau IANA du navigateur de l'utilisateur, envoyé avec chaque
// message (voir rememberUserTZ).
const stUserTZKey = "user_tz"
// rememberUserTZ retient le fuseau du navigateur (« Europe/Paris ») s'il est
// valide et a changé. Le conteneur tourne en UTC : sans ça, « rappelle-moi à
// 17h » tombait deux heures à côté (AJEAN 0.17.5).
func rememberUserTZ(tz string) {
tz = strings.TrimSpace(tz)
if tz == "" || tz == getStr(bkState, stUserTZKey) {
return
}
if _, err := time.LoadLocation(tz); err == nil {
_ = putStr(bkState, stUserTZKey, tz)
}
}
// defaultTaskTZ : le fuseau de l'utilisateur pour ce que le modèle planifie.
// Celui du navigateur s'il est connu, sinon celui d'une tâche existante (posé
// par l'UI), sinon vide = heure locale du serveur (souvent UTC).
func defaultTaskTZ() string {
if tz := getStr(bkState, stUserTZKey); tz != "" {
return tz
}
for _, t := range listTasks() {
if strings.TrimSpace(t.TZ) != "" {
return t.TZ
@@ -189,8 +210,30 @@ func toolTaskCreate(args map[string]any) string {
prompt := strings.TrimSpace(taskStrArg(args["prompt"]))
script := strings.TrimSpace(taskStrArg(args["script"]))
schedule := strings.TrimSpace(taskStrArg(args["schedule"]))
tz := defaultTaskTZ()
// Tâche unique : « dans N minutes » ou « à HH:MM », calculés ICI dans le
// fuseau de l'utilisateur (AJEAN 0.17.5). Le modèle n'a plus à jongler avec
// les fuseaux ni le cron pour un simple rappel.
if schedule == "" {
now := time.Now().In(loc(tz))
if v, ok := args["in_minutes"].(float64); ok && v >= 1 {
schedule = "@once " + now.Add(time.Duration(v)*time.Minute).Format("2006-01-02 15:04")
} else if at := strings.TrimSpace(taskStrArg(args["at"])); at != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04", at, loc(tz)); err == nil {
schedule = "@once " + t.Format("2006-01-02 15:04")
} else if hh, mm, err := parseHHMM(at); err == nil {
t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc(tz))
if !t.After(now) {
t = t.AddDate(0, 0, 1)
}
schedule = "@once " + t.Format("2006-01-02 15:04")
} else {
return "[erreur] 'at' invalide : « HH:MM » ou « AAAA-MM-JJ HH:MM »"
}
}
}
if name == "" || schedule == "" {
return "[erreur] name et schedule sont obligatoires"
return "[erreur] name obligatoire, et schedule (tâche répétée) ou in_minutes / at (tâche unique)"
}
kind := ""
if script != "" {
@@ -202,7 +245,6 @@ func toolTaskCreate(args map[string]any) string {
} else if prompt == "" {
return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)"
}
tz := defaultTaskTZ()
if err := validateSchedule(schedule, tz); err != nil {
return "[erreur] fréquence invalide : " + err.Error()
}
@@ -223,7 +265,7 @@ func toolTaskCreate(args map[string]any) string {
}
when := ""
if t.NextRun > 0 && enabled {
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04")
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04") + " (" + tzLabel(tz) + ")"
}
return fmt.Sprintf("[ok] tâche '%s' créée (id %s)%s", name, t.ID, when)
}
@@ -279,3 +321,11 @@ func toolTaskDelete(args map[string]any) string {
}
return fmt.Sprintf("[ok] tâche '%s' (id %s) supprimée", t.Name, id)
}
// tzLabel : nom du fuseau à montrer au modèle.
func tzLabel(tz string) string {
if tz == "" {
return "heure du serveur"
}
return tz
}
+121
View File
@@ -0,0 +1,121 @@
package loki
import (
"crypto/rand"
"encoding/hex"
"strings"
"sync/atomic"
bolt "go.etcd.io/bbolt"
)
// tool_results.go — résultats COMPLETS des outils, pour le bouton « voir plus ».
// Repris d'AJEAN 0.15.5 / 0.15.7. Chiffré comme les discussions quand le
// chiffrement mémoire est actif (bkToolRes figure dans encryptedBuckets).
//
// Le flux n'envoie à l'UI qu'un aperçu (toolPreviewChars) ; le reste se charge
// au clic. Il ne peut pas être cherché dans conv.Messages : pendant un tour
// d'agent, les messages tool n'y sont versés qu'à la FIN du tour, et après un
// compactage les anciens ont disparu de la vue modèle. On garde donc chaque
// résultat coupé dans un bucket dédié, sous un id PROPRE (pas le tool_call_id,
// que certains parseurs réutilisent d'un tour à l'autre).
//
// Rangement PAR DISCUSSION : la clé est « <id de discussion>.<aléa> ». Les
// résultats vivent aussi longtemps que leur discussion (supprimés avec elle,
// voir deleteToolResultsFor) ; les orphelins sont nettoyés de temps en temps.
const (
bkToolRes = "toolres"
toolResPruneGap = 200 // nettoyage tenté toutes les N écritures
)
var toolResWrites atomic.Int64
func toolResID(sid string) string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
if sid = strings.TrimSpace(sid); sid == "" {
sid = "nosession"
}
return sid + "." + hex.EncodeToString(b[:])
}
// saveToolResult enregistre un résultat complet de la discussion ACTIVE et
// renvoie son id ("" en cas d'échec : l'appelant envoie alors le résultat
// entier dans le flux).
func saveToolResult(result string) string {
id := toolResID(getStr(bkChat, ckActive))
// Chiffrement actif mais mémoire verrouillée : putStoreBytes refuse d'écrire
// en clair, et l'appelant envoie alors le résultat entier dans le flux.
if id == "" || putStoreBytes(bkToolRes, id, []byte(result)) != nil {
return ""
}
if toolResWrites.Add(1)%toolResPruneGap == 0 {
go pruneToolResults()
}
return id
}
// loadToolResult relit un résultat enregistré.
func loadToolResult(id string) (string, bool) {
if strings.ContainsAny(id, "/\\") {
return "", false
}
b, err := getStoreBytesErr(bkToolRes, id)
if err != nil || b == nil {
return "", false
}
return string(b), true
}
// deleteToolResultsFor supprime les résultats d'une discussion supprimée.
func deleteToolResultsFor(sid string) {
if sid == "" {
return
}
prefix := sid + "."
_ = update(bkToolRes, func(b *bolt.Bucket) error {
c := b.Cursor()
var keys [][]byte
for k, _ := c.Seek([]byte(prefix)); k != nil && strings.HasPrefix(string(k), prefix); k, _ = c.Next() {
keys = append(keys, append([]byte(nil), k...))
}
for _, k := range keys {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
})
}
// pruneToolResults supprime les résultats de discussions qui n'existent plus.
func pruneToolResults() {
// Mémoire verrouillée : l'index des discussions est illisible et reviendrait
// vide — tout passerait pour orphelin. On attend le déverrouillage.
if memEncActive() && !memUnlocked() {
return
}
alive := map[string]bool{getStr(bkChat, ckActive): true}
for _, m := range convIndex() {
alive[m.ID] = true
}
_ = update(bkToolRes, func(b *bolt.Bucket) error {
var orphans []string
_ = b.ForEach(func(k, _ []byte) error {
key := string(k)
if i := strings.LastIndexByte(key, '.'); i > 0 && key[:i] != "nosession" && !alive[key[:i]] {
orphans = append(orphans, key)
}
return nil
})
for _, k := range orphans {
if err := b.Delete([]byte(k)); err != nil {
return err
}
}
return nil
})
}
+72
View File
@@ -0,0 +1,72 @@
package loki
import (
"strings"
"testing"
)
// Un résultat coupé est rangé à part, relu en entier, et part avec sa discussion.
func TestToolResultsSaveLoadDelete(t *testing.T) {
testHome(t)
_ = putStr(bkChat, ckActive, "c42")
long := strings.Repeat("ligne de sortie\n", 500)
ev := fillToolResult(&ToolUsedEvent{Name: "bash", Done: true}, long)
if ev.ResultID == "" || !strings.HasPrefix(ev.ResultID, "c42.") {
t.Fatalf("id attendu préfixé par la discussion, got %q", ev.ResultID)
}
if len([]rune(ev.Result)) != toolPreviewChars || ev.ResultChars != len([]rune(long)) {
t.Fatalf("aperçu %d car., taille %d — attendu %d et %d", len([]rune(ev.Result)), ev.ResultChars, toolPreviewChars, len([]rune(long)))
}
if got, ok := loadToolResult(ev.ResultID); !ok || got != long {
t.Fatal("le résultat complet doit être relisible")
}
short := fillToolResult(&ToolUsedEvent{}, "ok")
if short.ResultID != "" || short.Result != "ok" {
t.Fatal("un résultat court part entier, sans id")
}
deleteToolResultsFor("c42")
if _, ok := loadToolResult(ev.ResultID); ok {
t.Fatal("les résultats doivent partir avec leur discussion")
}
}
// Une sortie de commande coupée le DIT, avec sa taille réelle.
func TestTailOutputAnnouncesCut(t *testing.T) {
if tailOutput("court") != "court" {
t.Fatal("une sortie courte reste intacte")
}
out := tailOutput(strings.Repeat("x", toolMaxOutput+10))
if !strings.HasPrefix(out, "[sortie tronquée : ") || !strings.Contains(out, "au total") {
t.Fatalf("mention de coupe absente : %q", out[:60])
}
}
// Mémoire chiffrée : le résultat complet est chiffré en base et relu intact ;
// verrouillée, rien n'est écrit (le flux portera le résultat entier) et le
// nettoyage des orphelins ne tourne pas (l'index illisible ferait tout effacer).
func TestToolResultsChiffres(t *testing.T) {
testHome(t)
clearMemDEK()
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
full := strings.Repeat("ligne de sortie\n", 500)
id := saveToolResult(full)
if id == "" {
t.Fatal("résultat non enregistré")
}
if !looksEncrypted(getBytes(bkToolRes, id)) {
t.Fatal("résultat en clair alors que la mémoire est chiffrée")
}
if got, ok := loadToolResult(id); !ok || got != full {
t.Fatal("résultat chiffré non restitué à l'identique")
}
clearMemDEK()
if saveToolResult("autre") != "" {
t.Fatal("mémoire verrouillée : rien n'aurait dû être écrit")
}
pruneToolResults()
if getBytes(bkToolRes, id) == nil {
t.Fatal("le nettoyage a effacé un résultat pendant que la mémoire était verrouillée")
}
}
+50
View File
@@ -0,0 +1,50 @@
package loki
import (
"bytes"
"encoding/json"
)
// orderedProps : propriétés d'un schéma d'outil sérialisées DANS L'ORDRE donné.
//
// Une map Go sort ses clés par ordre alphabétique : write annonçait content
// AVANT file, edit new avant old. Le modèle écrit ses arguments dans l'ordre
// du schéma — il déroulait donc tout le contenu d'un fichier avant d'en donner
// le chemin. Le chemin d'abord permet de refuser en plein flux une écriture
// interdite (fichier non lu) au lieu d'attendre la fin, et à l'interface de
// nommer le fichier dès le début de la frappe (repris d'OpenFox, 2.0.154).
type orderedProps []propEntry
type propEntry struct {
Key string
Schema map[string]any
}
func (o orderedProps) MarshalJSON() ([]byte, error) {
var b bytes.Buffer
b.WriteByte('{')
for i, p := range o {
if i > 0 {
b.WriteByte(',')
}
k, err := json.Marshal(p.Key)
if err != nil {
return nil, err
}
v, err := json.Marshal(p.Schema)
if err != nil {
return nil, err
}
b.Write(k)
b.WriteByte(':')
b.Write(v)
}
b.WriteByte('}')
return b.Bytes(), nil
}
// preflightRefusal : write/edit refusé pendant le flux, dès que son chemin est
// connu (voir runChat). msg vide = chemin vérifié et accepté.
type preflightRefusal struct {
name, id, file, msg string
}
+336 -127
View File
@@ -478,29 +478,19 @@ button:disabled{opacity:.5;cursor:not-allowed}
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
.diff-count .a{color:var(--add)}
.diff-count .d{color:var(--del)}
/* Bandeau « début de la discussion masqué » : le serveur ne rejoue que la fin
d'un très long fil (replayTailMax). Discret, en tête du fil, avec le bouton
qui va chercher le reste. */
.replay-banner{margin:4px auto 10px;padding:6px 10px;font-size:11px;color:var(--dim);background:var(--panel);border:1px solid var(--border);border-radius:3px;text-align:center;max-width:680px}
.replay-banner .replay-more{padding:2px 8px;font-size:11px;background:none;border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer}
.replay-banner .replay-more:hover{color:var(--accent);border-color:var(--accent)}
.replay-banner .replay-more:disabled{cursor:default;opacity:.6}
.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
/* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre
ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre
scrolle (max-height), donc la barre est portée par le bloc-parent NON
scrollant (.tool-block) : elle reste au coin quoi qu'on scrolle. */
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
/* Résultat d'outil : « voir plus » + « copier » réunis dans une barre ancrée en
bas à droite. Le pre scrolle (max-height), donc la barre est portée par le
bloc-parent NON scrollant (.tool-block) : elle reste au coin. Déplié, le pre
lève son plafond pour montrer VRAIMENT plus (AJEAN 0.15.4). */
.msg.tool .tool-block{position:relative}
.msg.tool .tool-block .tool-actions{position:absolute;bottom:7px;right:7px;display:flex;gap:6px;align-items:center;z-index:1}
.msg.tool .tool-actions .copybtn{position:static;bottom:auto;right:auto}
.msg.tool .tool-more{padding:3px 8px;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;opacity:.5;transition:opacity .15s,color .15s,border-color .15s}
.msg.tool .tool-actions .tool-more{margin:0;padding:3px 8px;font:inherit;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;cursor:pointer;transition:opacity .15s,color .15s,border-color .15s}
.msg.tool .tool-block:hover .tool-actions .tool-more,.msg.tool .tool-block:hover .tool-actions .copybtn{opacity:1}
.msg.tool .tool-more:hover{border-color:var(--accent);color:var(--accent)}
.msg.tool .tool-more:disabled{cursor:default}
/* Déplié : on lève le plafond de hauteur pour voir VRAIMENT plus, au lieu de
rester enfermé dans une fenêtre de 280 px qu'il faut scroller. */
.msg.tool .tool-actions .tool-more:hover{border-color:var(--accent);color:var(--accent)}
.msg.tool pre.expanded{max-height:none}
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
.msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
@@ -1255,6 +1245,19 @@ html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
#attach-list::-webkit-scrollbar{display:none}
#attach-list.show{display:flex}
#composer:has(#attach-list.show){--fade:6px}
/* File d'attente : messages envoyés pendant une réponse, pas encore pris en
compte. Même bande que les pièces jointes, en gris. */
#queue-list{order:0;display:none;flex-direction:column;align-items:flex-end;gap:4px;margin:0 var(--col);padding:0 2px 8px}
#queue-list.show{display:flex}
#composer:has(#queue-list.show){--fade:6px}
#queue-list .queued-msg{max-width:80%;padding:5px 12px;border-radius:12px;background:var(--panel);
border:1px dashed var(--border);color:var(--dim);font-size:12px;line-height:1.35;
white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
box-shadow:0 2px 10px rgba(0,0,0,.10)}
#queue-list .queued-msg::before{content:"en attente · ";font-style:italic;opacity:.8}
/* Pendant une réponse, envoyer reste possible dès qu'il y a du texte : le
message part en file (voir send()). Stop reste à côté. */
html[data-busy="1"][data-hastext="1"] #send{display:inline-flex}
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
cadre, sinon le texte de la conversation transparaît derrière. */
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
@@ -1746,6 +1749,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
border:0;padding:8px 11px;text-align:left;cursor:pointer}
.conv-more:hover{color:var(--text)}
/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
agent est en train de travailler, y compris depuis un autre appareil. */
@@ -2137,6 +2143,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
#criteria-list li.crit-passed{color:var(--accent)}
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
#criteria-list li.crit-pending{color:var(--dim)}
#criteria-list li.crit-completed{color:var(--text)}
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
@@ -2242,6 +2249,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
.md-report table{border-collapse:collapse;margin:.5em 0}
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
/* Historique paginé : bouton « échanges précédents » en tête du fil. */
#chat .history-more{display:flex;justify-content:center;padding:6px 0 14px}
#chat .history-more button{font:inherit;font-size:12px;color:var(--dim);background:var(--row-bg);
border:1px solid var(--border);border-radius:999px;padding:6px 14px;cursor:pointer;
transition:background .15s,color .15s,border-color .15s}
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
#chat .history-more button:disabled{opacity:.6;cursor:default}
</style></head><body>
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -2371,6 +2386,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<!-- Messages envoyés PENDANT une réponse (file d'attente, voir send()) : en
gris au-dessus de la carte jusqu'à ce que le serveur les prenne en compte
— injectés dans la réponse en cours, ou traités au tour suivant. -->
<div id="queue-list" aria-live="polite"></div>
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
@@ -2484,6 +2503,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row plain">
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
</div>
<div class="pe-row stack">
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
<div id="ext-test-out" class="pe-note"></div>
@@ -2584,9 +2606,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div id="mem-block" class="gated-group">
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
<div class="slist">
<label class="switchrow"><span>mode</span>
<label class="switchrow"><span>mode <small class="dim">(projet actif)</small></span>
<select id="mem-mode" onchange="setMemMode()" class="sctl">
<option value="always">auto</option>
<option value="always">auto, injectée</option>
<option value="search">auto, recherche</option>
<option value="ondemand">sur demande</option>
<option value="off">désactivée</option>
</select>
@@ -3439,7 +3462,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="">f16 (max qualité)</option>
<option value="q8_0">q8_0 (léger)</option>
<option value="q4_0">q4_0 (très léger)</option>
<option value="q5_1">q5_1</option>
<option value="q5_1">q5_1 (lent sur CUDA)</option>
<option value="bf16">bf16</option>
</select></span></span>
</div>
@@ -3492,13 +3515,19 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
</div>
<!-- Chargement du modèle : --load-mode de llama.cpp (remplace --mlock et
--no-mmap). Pour un moteur ancien, loki serve retraduit au lancement
(normalizeLoadFlags). -->
<div class="pe-row">
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
<option value="">auto</option>
<option value="mmap">mmap</option>
<option value="none">none</option>
<option value="mlock">mlock</option>
<option value="mmap+mlock">mmap+mlock</option>
<option value="dio">dio</option>
</select></span></span>
</div>
</div>
</div>
@@ -3914,8 +3943,17 @@ function askKeyOnce(){
}
async function jget(u){ const r=await jfetch(u); return r.json(); }
async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); }
let LAST_PRESET=null; // id du preset actif vu au dernier poll (détecte une bascule faite ailleurs)
async function loadStatus(){
const s=await jget('/api/status');
// Preset actif changé alors qu'on n'a rien touché ici : un AUTRE appareil a
// basculé de preset/modèle. On rafraîchit la liste (et le libellé du preset)
// pour rester en phase sans recharger l'app. Premier poll (LAST_PRESET null) :
// on note seulement la valeur, loadPresets a déjà tourné au démarrage.
if(typeof s.preset!=='undefined'){
if(LAST_PRESET!==null && s.preset!==LAST_PRESET && typeof loadPresets==='function') loadPresets();
LAST_PRESET=s.preset;
}
const el=document.getElementById('status-svc');
// Trois états : service coupé (err) · service actif mais modèle pas encore
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
@@ -4288,7 +4326,7 @@ async function loadPresets(){
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name);
row.onclick=()=>switchTo(i+1, x.name, x.id);
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
@@ -4542,9 +4580,10 @@ async function toggleCompact(){
const on=document.getElementById('compact-toggle').checked;
await jpost('/api/agent/compact',{on});
}
// Mode mémoire (3 états) — indépendant du mode agent.
// Mode mémoire (4 états), réglé PAR PROJET — chaque projet garde le sien.
const MEM_DESC={
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
always:'L\'index des pages est placé en tête de conversation : l\'IA voit d\'emblée ce qu\'elle sait, lit directement la bonne page et sauve d\'elle-même ce qui mérite d\'être retenu. Réglage propre au projet actif.',
search:'Rien n\'est injecté : l\'IA cherche dans sa mémoire avant chaque tâche. Contexte plus léger, démarrage plus rapide. Réglage propre au projet actif.',
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
};
@@ -4893,14 +4932,15 @@ async function runBenchUI(){
loadPresets();
}
}
async function switchTo(n,name){
async function switchTo(n,name,id){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
toast('switching…');
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
pendingPreset = n; loadPresets();
const r=await jpost('/api/switch',{n:n});
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
const r=await jpost('/api/switch',id?{id:id}:{n:n});
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
toast('switched');
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
@@ -5778,8 +5818,47 @@ function populateSettings(){
}
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));
const lm = eaLoadMode();
set('s-loadmode', lm);
syncLoadModeSub(lm);
}
// Chargement du modèle. llama.cpp récent a REMPLACÉ --mlock / --no-mmap par
// --load-mode (auto, none, mmap, mlock, mmap+mlock, dio). L'éditeur écrit la
// forme moderne ; au lancement, loki serve la retraduit pour un moteur ancien
// (normalizeLoadFlags côté Go), et inversement.
function eaLoadMode(){
const lm = (eaGetValued('--load-mode') || eaGetValued('-lm')).toLowerCase();
if(lm) return lm === 'auto' ? '' : lm;
// Preset écrit avec les anciens drapeaux : même correspondance que le serveur.
const mlock = eaHasFlag('--mlock'), nommap = eaHasFlag('--no-mmap');
if(mlock && nommap) return 'mlock';
if(mlock) return 'mmap+mlock';
if(nommap) return 'none';
if(eaHasFlag('--mmap')) return 'mmap';
return '';
}
function eaSetLoadMode(mode){
const t = eaTokens().filter(x=>x!=='--mlock' && x!=='--no-mmap' && x!=='--mmap' && !x.startsWith('--load-mode='));
for(const f of ['--load-mode','-lm']){
const i = t.indexOf(f);
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
}
if(mode) t.push('--load-mode', mode);
eaSetTokens(t);
syncLoadModeSub(mode);
}
// Sous-titre : ce que fait le mode choisi (les options ne portent que son nom).
const LOAD_MODE_SUB = {
'': 'mmap, sauf si un périphérique ne le permet pas',
'mmap': 'fichier mappé, lu à la demande',
'none': 'tout charger, sans mmap',
'mlock': 'tout en RAM, verrouillé (sans mmap)',
'mmap+mlock': 'mappé et verrouillé en RAM',
'dio': 'lecture DirectIO si disponible',
};
function syncLoadModeSub(mode){
const el = document.getElementById('s-loadmode-sub');
if(el) el.textContent = LOAD_MODE_SUB[mode] ?? mode;
}
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
@@ -6350,15 +6429,32 @@ addEventListener('DOMContentLoaded', ()=>{
if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c);
});
// iOS ANNULE un appui si la position de défilement change pendant qu'il a lieu.
// Le fil se recalant en bas à chaque token pendant la génération, presque chaque
// tap tombait dessus : plus rien ne répondait (pas même stop) jusqu'à la fin de
// la réponse. On suspend donc le suivi le temps du contact, et on rattrape juste
// après (TOUCH_GRACE). Repris d'AJEAN 0.15.7.
let TOUCHING = false, _touchEnd = 0;
const TOUCH_GRACE = 350;
(function(){
const on = ()=>{ TOUCHING = true; };
const off = ()=>{ TOUCHING = false; _touchEnd = performance.now(); if(stickyBottom) setTimeout(()=>scrollMaybe(), TOUCH_GRACE + 20); };
document.addEventListener('touchstart', on, {passive:true, capture:true});
document.addEventListener('touchend', off, {passive:true, capture:true});
document.addEventListener('touchcancel', off, {passive:true, capture:true});
})();
function scrollMaybe(){
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
if(REPLAYING) return;
if(stickyBottom){
if(stickyBottom && !TOUCHING && performance.now() - _touchEnd >= TOUCH_GRACE){
const c = chatEl();
c.scrollTop = c.scrollHeight;
const target = c.scrollHeight - c.clientHeight;
// N'écrire que si l'on n'y est pas déjà : sinon on relance pour rien la
// machinerie de défilement d'iOS, qui continue de voler les taps.
if(Math.abs(c.scrollTop - target) > 1) c.scrollTop = c.scrollHeight;
}
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
}
@@ -6560,6 +6656,9 @@ function hydrateImages(root){
}
});
}
// Lignes d'un contenu en cours d'écriture, comptées comme côté serveur : un saut
// de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
function bodyLineCount(s){ s=String(s).replace(/\r\n/g,'\n').replace(/\n$/,''); return s ? s.split('\n').length : 0; }
// Render a tool call as its own conversation message: the command the model
// wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){
@@ -6601,16 +6700,18 @@ function renderToolMsg(el, tu){
meta = meta || {lbl:'mémoire', head:'mémoire'};
let lbl = meta.lbl;
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
// Taille RÉELLE (result_chars) quand le flux n'a envoyé qu'un aperçu — sinon
// la bulle affichait toujours le même chiffre, celui du plafond d'aperçu.
const realChars = tu.result_chars || (tu.result ? tu.result.length : 0);
if(realChars){ lbl += ' · ~' + Math.max(1, Math.round(realChars/4)) + ' tok'; }
// Taille RÉELLE (result_chars) : le flux ne porte plus qu'un aperçu.
if(tu.result){ const n = tu.result_chars || tu.result.length; lbl += ' · ~' + Math.max(1, Math.round(n/4)) + ' tok'; }
setLabel(el, lbl);
// Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
// reporté sur l'étiquette, pour rester lisible bulle repliée.
// Les vrais totaux viennent du serveur (added/removed) : le diff lui-même est
// tronqué pour l'affichage, le recompter donnait « +120 » pour 500 lignes.
// Repli sur le décompte du diff pour les conversations d'avant ces champs.
let add=0, del=0;
if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
else if(tu.body){ add=tu.body.split('\n').length; }
if(tu.added!=null || tu.removed!=null){ add=tu.added||0; del=tu.removed||0; }
else if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
else if(tu.body){ add=bodyLineCount(tu.body); }
if(add||del) setLabelCounts(el, add, del);
const body=bodyOf(el); body.innerHTML='';
const head=document.createElement('div'); head.className='tool-head';
@@ -6684,12 +6785,9 @@ function renderToolMsg(el, tu){
body.appendChild(sub);
const pre=document.createElement('pre');
const code=document.createElement('code');
const preview=tu.result, rid=tu.result_id, PREVIEW=1600;
// Deux cas :
// - result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
// « voir plus » CHARGE le reste à la demande via l'API ;
// - sinon : le résultat est déjà complet ; s'il est long, « voir plus » le
// déplie localement (rien à charger).
const preview=tu.result, rid=tu.result_id;
// result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
// « voir plus » CHARGE le reste à la demande (AJEAN 0.15.1 / 0.15.4).
if(rid){
code.textContent=preview+'…';
pre.appendChild(code);
@@ -6698,29 +6796,19 @@ function renderToolMsg(el, tu){
let full=null, open=false;
more.onclick=async(e)=>{ e.stopPropagation();
if(full===null){
more.disabled=true; more.textContent='…';
// Résultat introuvable côté serveur (compacté, autre discussion) : on
// garde l'aperçu plutôt que de vider la bulle.
try{ const j=await jget('/api/chat/tool-result?id='+encodeURIComponent(rid)); full=(j&&j.result)||preview; }
catch(_){ full=preview; }
more.disabled=false;
// Un échec n'est PAS mémorisé : on prévient et on laisse réessayer.
more.disabled=true; const prevTxt=more.textContent; more.textContent='…';
let got=null;
try{ const r=await jfetch('/api/chat/tool-result?id='+encodeURIComponent(rid)); const j=await r.json(); if(r.ok && j && typeof j.result==='string') got=j.result; }
catch(_){}
more.disabled=false; more.textContent=prevTxt;
if(got===null){ toast('résultat complet indisponible'); return; }
full=got;
}
open=!open;
code.textContent = open ? full : preview+'…';
pre.classList.toggle('expanded', open);
more.textContent = open ? 'voir moins' : 'voir plus';
};
body.appendChild(toolBlock(pre, more));
} else if(preview.length>PREVIEW){
code.textContent=preview.slice(0,PREVIEW)+'…';
pre.appendChild(code);
const more=document.createElement('button');
more.className='tool-more'; more.type='button'; more.textContent='voir plus';
let open=false;
more.onclick=(e)=>{ e.stopPropagation(); open=!open;
code.textContent = open ? preview : preview.slice(0,PREVIEW)+'…';
pre.classList.toggle('expanded', open);
more.textContent = open ? 'voir moins' : 'voir plus';
more.textContent = open ? 'réduire' : 'voir plus';
};
body.appendChild(toolBlock(pre, more));
} else {
@@ -6739,7 +6827,7 @@ function renderToolMsg(el, tu){
}
// Bloc d'un résultat d'outil : le pre scrolle (max-height), donc la barre
// d'actions vit sur un bloc-parent NON scrollant, ancrée en bas à droite — elle
// reste au coin quoi qu'on scrolle dans le résultat. La barre réunit « voir
// reste au coin même quand on scrolle dans le résultat. Elle réunit « voir
// plus » (passé ici) et, ajouté ensuite par addCopyButtons, « copier ».
function toolBlock(pre, more){
const block=document.createElement('div'); block.className='tool-block';
@@ -6752,9 +6840,8 @@ function toolBlock(pre, more){
// Inject a "copier" button into every <pre> code block (idempotent).
function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{
// Résultat d'outil : le pre est enrobé dans .tool-block et la barre
// d'actions est SŒUR du pre (pas dedans) ; on cherche donc le copier dans
// ce périmètre.
// Résultat d'outil : la barre d'actions est SŒUR du pre (pas dedans) ; on
// cherche donc le copier dans le bloc qui les réunit.
const scope = pre.closest('.tool-block') || pre;
if(scope.querySelector('.copybtn')) return;
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
@@ -6769,8 +6856,8 @@ function addCopyButtons(root){
btn.textContent='copié ✓'; btn.classList.add('done');
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
};
// S'il y a une barre d'actions (résultat d'outil avec « voir plus »), le
// bouton copier s'y range à côté ; sinon il se colle en bas à droite du bloc.
// Barre d'actions (résultat d'outil avec « voir plus ») : le copier s'y
// range à côté ; sinon il se colle en bas à droite du bloc.
(scope.querySelector('.tool-actions')||pre).appendChild(btn);
});
}
@@ -6879,6 +6966,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(
function autoGrow(ta){
ta = ta || document.getElementById('input');
if(!ta) return;
// Du texte à envoyer ? Pendant une réponse, c'est ce qui fait réapparaître
// « envoyer » à côté de stop (message mis en file, voir send()).
if(ta.id==='input') document.documentElement.setAttribute('data-hastext', ta.value.trim() ? '1' : '0');
ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
@@ -6890,10 +6980,39 @@ function autoGrow(ta){
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
let lastSeq=0, streamAbort=null;
// REPLAY_FULL : demander le journal ENTIER au prochain abonnement. Le serveur
// ne rejoue par défaut que la fin d'une longue discussion (voir replayTailMax) ;
// le bandeau « charger le début » rouvre le flux avec ce drapeau.
let REPLAY_FULL=false;
// Historique paginé (AJEAN 0.15.7) : au chargement, le serveur ne rejoue que les
// HIST_TAIL derniers échanges et annonce le reste ({history_more: N}). Le bouton
// en haut du fil redemande alors un rejeu complet (HIST_FULL) en gardant la
// position de lecture (HIST_RESTORE = distance au bas du fil, réappliquée au
// caught_up).
const HIST_TAIL=20;
let HIST_FULL=false, HIST_RESTORE=null;
// Discussion AFFICHÉE (id reçu au caught_up/reset). Renvoyée à chaque
// (ré)abonnement : si un autre appareil a changé de discussion pendant une
// coupure, le serveur ordonne un reset au lieu de greffer le nouveau fil sur
// l'ancien (AJEAN 0.14.0).
let CONV_ID='';
// File d'attente (AJEAN 0.14.0) : messages envoyés PENDANT une réponse, montrés
// en gris au-dessus de la carte jusqu'à ce que le flux les confirme (delta
// `user` du même texte) — injectés en cours de réponse ou au tour suivant.
function queueAdd(text){
const box=document.getElementById('queue-list'); if(!box) return null;
const el=document.createElement('div'); el.className='queued-msg'; el.textContent=text||'(pièce jointe)';
el.dataset.text=text;
box.appendChild(el); box.classList.add('show');
return el;
}
function queueSync(){ const box=document.getElementById('queue-list'); if(box) box.classList.toggle('show', !!box.children.length); }
function queueTake(text){
const box=document.getElementById('queue-list'); if(!box) return;
const el=[...box.children].find(e=>e.dataset.text===text);
if(el) el.remove();
queueSync();
}
function queueClear(){ const box=document.getElementById('queue-list'); if(box) box.textContent=''; queueSync(); }
// Identifiant d'envoi, stable d'un réessai à l'autre : le serveur ne met pas
// deux fois le même message en file si la réponse s'est perdue en route.
function newCID(){ try{ return crypto.randomUUID(); }catch(_){ return Date.now().toString(36)+Math.random().toString(36).slice(2); } }
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
@@ -7094,7 +7213,8 @@ function renderStats(el, s){
if(!el||!s) return;
const parts=[];
const pt=s.prompt_tokens||s.prompt_tokens_total;
if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
// Débit de lecture inconnu (API tierce sans timings) : on n'affiche que la taille.
if(pt) parts.push('prefill '+pt+' tok'+(s.prompt_per_second ? ' · '+s.prompt_per_second.toFixed(0)+' tok/s' : ''));
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
if(!parts.length) return;
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
@@ -7293,54 +7413,38 @@ function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoot
// bulle d'outil) doit voir le texte complet.
function settleBlocks(){ smoothSnap(); flushRender(); }
// showReplayBanner affiche, en tête du fil, le bandeau « début masqué ». Un clic
// coupe le flux et se réabonne depuis zéro en demandant TOUT le journal.
function showReplayBanner(hidden){
const c=chatEl(); if(!c) return;
let b=document.getElementById('replay-banner');
if(!b){
b=document.createElement('div');
b.id='replay-banner'; b.className='replay-banner';
c.insertBefore(b, c.firstChild);
}
b.textContent='';
const txt=document.createElement('span');
txt.textContent='Début de la discussion masqué ('+hidden+' événements) — ';
const btn=document.createElement('button');
btn.type='button'; btn.className='replay-more'; btn.textContent='charger le début';
btn.onclick=()=>{ btn.disabled=true; btn.textContent='chargement…'; loadFullReplay(); };
b.appendChild(txt); b.appendChild(btn);
}
// loadFullReplay relance l'abonnement depuis le début, journal entier. On repart
// d'un fil VIDE : rejouer par-dessus l'existant ferait doublon.
function loadFullReplay(){
REPLAY_FULL=true; lastSeq=0; REPLAYING=true;
if(streamAbort) streamAbort.abort();
const c=chatEl(); if(c){ c.innerHTML=''; c.style.opacity='0'; }
PENDING=null; newTurn();
setChatLoading('chargement du début de la conversation…');
connectStream();
// Rafraîchissement groupé de la liste des discussions (plusieurs événements
// rapprochés = un seul appel).
let histRefreshTimer=null;
function histRefreshSoon(){
if(typeof loadConversations!=='function') return;
clearTimeout(histRefreshTimer);
histRefreshTimer=setTimeout(()=>{ histRefreshTimer=null; loadConversations(); }, 400);
}
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique.
function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
// Replay borné : le serveur a sauté le début d'une longue discussion. On
// l'annonce en tête du fil, avec le bouton qui va le chercher. Rien n'est
// perdu : le journal complet est sur le disque.
if(typeof d.replay_truncated==='number'){ showReplayBanner(d.replay_truncated); return; }
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
if(d.caught_up){
if(typeof d.id==='string') CONV_ID=d.id;
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
if(REPLAYING && HIST_RESTORE!==null){
// Historique complet demandé : on garde la position de lecture (distance au
// bas du fil) au lieu de sauter en bas, et on la recale une fois la mise en
// page stabilisée (le rejeu pose ses blocs de façon différée).
REPLAYING=false; syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1';
const keep=HIST_RESTORE; HIST_RESTORE=null; stickyBottom=false;
const put=()=>{ c.scrollTop=Math.max(0, c.scrollHeight-keep); };
put(); requestAnimationFrame(()=>requestAnimationFrame(put)); setTimeout(put, 80); setTimeout(put, 250);
} else if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
@@ -7353,7 +7457,9 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; }
if(d.queue_dropped!==undefined){ if(!REPLAYING){ queueClear(); toast('messages en attente abandonnés'); } return; }
if(d.reset!==undefined){ if(typeof d.id==='string') CONV_ID=d.id; queueClear(); HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -7370,6 +7476,10 @@ function handleDelta(d){
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
// bulle de réponse du tour.
T.model = d.model || '';
if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte
// Le serveur a déjà enregistré la discussion (StartTurn persiste) : la liste
// la montre dès le premier message, sans attendre la fin de la réponse.
if(!REPLAYING) histRefreshSoon();
let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
@@ -7438,6 +7548,17 @@ function handleDelta(d){
return; }
if(d.reasoning_content){
killTyping('reasoning');
// Raisonnement qui reprend APRÈS du texte de réponse (modèle qui repense en
// cours de réponse, reprise après coupure) : il allait dans l'ANCIENNE bulle,
// déjà repliée au-dessus de la réponse. On en ouvre une nouvelle sous la
// réponse : l'ordre affiché suit l'ordre reçu (AJEAN 0.17.4).
// Réponse encore vide (simple saut de ligne avant la réflexion) : on retire
// la bulle vide et le raisonnement continue dans la sienne.
if(T.contentEl){
if((T.fullContent||'').trim()){ settleBlocks(); T.reasonEl=null; }
else { smoothReset(); cancelRender(); T.contentEl.remove(); }
T.contentEl=null;
}
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
@@ -7481,7 +7602,7 @@ async function connectStream(){
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController();
try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq, full:REPLAY_FULL}),signal:streamAbort.signal});
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL,conv_id:CONV_ID}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
@@ -7506,24 +7627,71 @@ async function connectStream(){
}
// Interrompt la génération en cours côté serveur (la goroutine détachée est
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
// _stopAt : anti-doublon, le bouton réagit à l'APPUI sur écran tactile (voir plus
// bas) puis le clic qui suit éventuellement ne doit pas relancer.
let _stopAt = 0;
function stopGen(){
const now = Date.now(); if(now - _stopAt < 800) return; _stopAt = now;
jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop');
}
// Sur écran tactile, stop agit dès l'appui (pointerdown) : le « clic » complet
// peut être annulé par iOS si le fil défile au même moment — or c'est justement
// quand l'IA écrit qu'on veut l'arrêter vite. Repris d'AJEAN 0.15.7.
document.addEventListener('DOMContentLoaded', ()=>{
const st=document.getElementById('stop'); if(!st) return;
st.addEventListener('pointerdown', (e)=>{ if(e.pointerType!=='mouse'){ e.preventDefault(); stopGen(); } });
});
// Bouton « échanges précédents » posé en tête du fil quand le serveur n'a rejoué
// que la fin de la conversation.
function showHistoryMore(n){
const chat=chatEl(); if(!chat || !n) return;
let box=chat.querySelector('.history-more');
if(!box){ box=document.createElement('div'); box.className='history-more'; chat.insertBefore(box, chat.firstChild); }
box.innerHTML='';
const b=document.createElement('button'); b.type='button';
b.textContent = n===1 ? 'afficher l\'échange précédent' : 'afficher les '+n+' échanges précédents';
b.onclick=loadFullHistory;
box.appendChild(b);
}
// Rejeu COMPLET de la conversation vive, en gardant ce qu'on lisait à l'écran :
// on vide le fil, on coupe le flux, et la boucle connectStream se reconnecte
// depuis le début sans pagination (tail=0).
function loadFullHistory(){
const chat=chatEl(); if(!chat) return;
const box=chat.querySelector('.history-more'); if(box){ const b=box.querySelector('button'); if(b){ b.disabled=true; b.textContent='chargement…'; } }
HIST_RESTORE=chat.scrollHeight-chat.scrollTop;
HIST_FULL=true;
chat.innerHTML=''; newTurn();
smoothReset(); cancelRender();
lastSeq=0; REPLAYING=true;
if(streamAbort){ try{ streamAbort.abort(); }catch(e){} }
}
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
// « network error » alarmiste alors que l'IA répond quand même.
// Fuseau du navigateur, envoyé avec chaque message : le conteneur tourne en UTC,
// et « rappelle-moi à 17h » doit tomber à 17h ici (voir rememberUserTZ).
const USER_TZ=(()=>{ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||''; }catch(_){ return ''; } })();
async function send(){
if(busy) return;
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim();
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
if(!text && !ATTACH.length) return;
ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
addPending(text);
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
// Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en
// compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche
// en attente au-dessus de la carte, pas dans le fil qui s'écrit encore.
const queuedAtSend = busy;
let qel=null;
// Sinon le message s'affiche TOUT DE SUITE dans le fil, en gris : il ne
// disparaît plus le temps de l'aller-retour. Il s'éclaircit quand le flux le
// confirme (confirmPending).
if(queuedAtSend) qel=queueAdd(text); else addPending(text);
const fail=(m)=>{ if(qel){ qel.remove(); queueSync(); } else clearPending(); toast(m); ta.value=text; autoGrow(ta); };
const cid=newCID();
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
// on doit pouvoir en enlever une, et un échec doit rester visible.
@@ -7531,13 +7699,20 @@ async function send(){
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
// Les pastilles passent dans la bulle en attente : le message porte ses
// fichiers dès l'envoi, sans attendre l'aller-retour.
if(PENDING) addMsgFiles(PENDING, attachSent());
if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){
try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
if(r.status===409 || r.ok) clearAttach();
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
if(r.ok) return; // la bulle + les tokens arrivent par le flux
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid,tz:USER_TZ})});
if(r.ok) clearAttach();
// 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
if(r.ok){
// Parti tout de suite alors qu'on le croyait en file (le tour venait de
// finir) : la pastille disparaît, la bulle arrive par le flux.
let j={}; try{ j=await r.json(); }catch(_){}
if(qel && j.queued===false){ qel.remove(); queueSync(); }
return; // la bulle + les tokens arrivent par le flux
}
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
}catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600));
@@ -7963,6 +8138,7 @@ async function openExternal(id){
document.getElementById('ext-test-out').textContent = '';
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
document.getElementById('ext-vision').checked = false;
showModal('ext-modal');
if(!id) return;
let d = {};
@@ -7970,6 +8146,7 @@ async function openExternal(id){
if(extEditing !== id) return; // une autre ouverture a pris la main
set('ext-name', d.name || ''); set('ext-url', d.url || '');
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
document.getElementById('ext-vision').checked = !!d.vision;
if(d.hasKey){
document.getElementById('ext-key-sub').textContent =
'une clé est enregistrée — laisse vide pour la conserver';
@@ -7986,6 +8163,7 @@ function extPayload(){
key: document.getElementById('ext-key').value || '',
ctx: (document.getElementById('ext-ctx').value || '').trim(),
keyTouched: extKeyTouched,
vision: document.getElementById('ext-vision').checked,
};
}
@@ -8015,9 +8193,11 @@ async function saveExternal(){
if(!p.model){ toast('modèle requis'); return; }
const r = await jpost('/api/preset/external/save', p);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('enregistré');
// Preset en service : le serveur l'a appliqué aussitôt (applied).
toast(r.applied ? 'enregistré et appliqué' : 'enregistré');
closeExternal();
loadPresets();
if(r.applied && typeof loadStatus === 'function') loadStatus();
}
// Même route de suppression que n'importe quel preset — et même refus si c'est
@@ -9064,6 +9244,12 @@ let CONVS = [], CONV_ACTIVE = '';
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
// route, ou ouverte depuis un autre appareil).
let CONV_BUSY = false;
// Liste paginée (AJEAN 0.15.6) : avec des centaines de discussions, construire
// toutes les lignes d'un coup figeait l'ouverture du panneau. On en dessine
// CONV_PAGE, puis la suite quand le bas de la liste devient visible. La
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
async function loadConversations(){
let r;
@@ -9086,6 +9272,7 @@ function renderConversations(){
if(!cont) return;
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
@@ -9098,7 +9285,10 @@ function renderConversations(){
convSyncBusy();
return;
}
for(const c of list){
// La discussion active reste toujours visible, même loin dans la liste.
const ai = list.findIndex(c => c.id === CONV_ACTIVE);
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
@@ -9136,10 +9326,28 @@ function renderConversations(){
row.append(info, when, acts);
cont.appendChild(row);
}
if(list.length > shown) convMoreRow(cont, list.length - shown);
syncTopbarTitle();
convSyncBusy();
}
// Ligne « N de plus » en bas de la liste : un clic, ou son arrivée à l'écran
// (IntersectionObserver), dessine la page suivante.
function convMoreRow(cont, left){
const more = document.createElement('button');
more.type = 'button';
more.className = 'conv-more';
more.textContent = 'afficher ' + Math.min(left, CONV_PAGE) + ' de plus (' + left + ' restantes)';
const next = () => { CONV_SHOWN += CONV_PAGE; renderConversations(); };
more.onclick = next;
cont.appendChild(more);
if(convMoreObs) convMoreObs.disconnect();
if(window.IntersectionObserver){
convMoreObs = new IntersectionObserver(es => { if(es.some(e => e.isIntersecting)){ convMoreObs.disconnect(); next(); } });
convMoreObs.observe(more);
}
}
// --- Discussion au travail : anneau qui tourne ------------------------------
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
@@ -9412,7 +9620,7 @@ function onModelSwitch(sel){
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
// pendant les quelques secondes du redémarrage.
switchTo(n, name);
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
}
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
@@ -9490,6 +9698,7 @@ function paintModelSwitch(){
MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.dataset.id = p.id || '';
o.textContent = p.name;
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
g.appendChild(o);
@@ -9686,7 +9895,7 @@ function renderCriteria(list){
for(const c of list){
const li = document.createElement('li');
li.className = 'crit-'+(c.status||'pending');
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : (c.status==='completed' ? '◐' : '○'));
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
ul.appendChild(li);
}
+23 -9
View File
@@ -163,6 +163,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<!-- Messages envoyés PENDANT une réponse (file d'attente, voir send()) : en
gris au-dessus de la carte jusqu'à ce que le serveur les prenne en compte
— injectés dans la réponse en cours, ou traités au tour suivant. -->
<div id="queue-list" aria-live="polite"></div>
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
@@ -276,6 +280,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row plain">
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
</div>
<div class="pe-row stack">
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
<div id="ext-test-out" class="pe-note"></div>
@@ -376,9 +383,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="mem-block" class="gated-group">
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
<div class="slist">
<label class="switchrow"><span>mode</span>
<label class="switchrow"><span>mode <small class="dim">(projet actif)</small></span>
<select id="mem-mode" onchange="setMemMode()" class="sctl">
<option value="always">auto</option>
<option value="always">auto, injectée</option>
<option value="search">auto, recherche</option>
<option value="ondemand">sur demande</option>
<option value="off">désactivée</option>
</select>
@@ -1231,7 +1239,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="">f16 (max qualité)</option>
<option value="q8_0">q8_0 (léger)</option>
<option value="q4_0">q4_0 (très léger)</option>
<option value="q5_1">q5_1</option>
<option value="q5_1">q5_1 (lent sur CUDA)</option>
<option value="bf16">bf16</option>
</select></span></span>
</div>
@@ -1284,13 +1292,19 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
</div>
<!-- Chargement du modèle : --load-mode de llama.cpp (remplace --mlock et
--no-mmap). Pour un moteur ancien, loki serve retraduit au lancement
(normalizeLoadFlags). -->
<div class="pe-row">
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
<option value="">auto</option>
<option value="mmap">mmap</option>
<option value="none">none</option>
<option value="mlock">mlock</option>
<option value="mmap+mlock">mmap+mlock</option>
<option value="dio">dio</option>
</select></span></span>
</div>
</div>
</div>
+9
View File
@@ -1,5 +1,14 @@
let LAST_PRESET=null; // id du preset actif vu au dernier poll (détecte une bascule faite ailleurs)
async function loadStatus(){
const s=await jget('/api/status');
// Preset actif changé alors qu'on n'a rien touché ici : un AUTRE appareil a
// basculé de preset/modèle. On rafraîchit la liste (et le libellé du preset)
// pour rester en phase sans recharger l'app. Premier poll (LAST_PRESET null) :
// on note seulement la valeur, loadPresets a déjà tourné au démarrage.
if(typeof s.preset!=='undefined'){
if(LAST_PRESET!==null && s.preset!==LAST_PRESET && typeof loadPresets==='function') loadPresets();
LAST_PRESET=s.preset;
}
const el=document.getElementById('status-svc');
// Trois états : service coupé (err) · service actif mais modèle pas encore
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
+4 -3
View File
@@ -56,7 +56,7 @@ async function loadPresets(){
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name);
row.onclick=()=>switchTo(i+1, x.name, x.id);
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
@@ -310,9 +310,10 @@ async function toggleCompact(){
const on=document.getElementById('compact-toggle').checked;
await jpost('/api/agent/compact',{on});
}
// Mode mémoire (3 états) — indépendant du mode agent.
// Mode mémoire (4 états), réglé PAR PROJET — chaque projet garde le sien.
const MEM_DESC={
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
always:'L\'index des pages est placé en tête de conversation : l\'IA voit d\'emblée ce qu\'elle sait, lit directement la bonne page et sauve d\'elle-même ce qui mérite d\'être retenu. Réglage propre au projet actif.',
search:'Rien n\'est injecté : l\'IA cherche dans sa mémoire avant chaque tâche. Contexte plus léger, démarrage plus rapide. Réglage propre au projet actif.',
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
};
+44 -4
View File
@@ -41,14 +41,15 @@ async function runBenchUI(){
loadPresets();
}
}
async function switchTo(n,name){
async function switchTo(n,name,id){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
toast('switching…');
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
pendingPreset = n; loadPresets();
const r=await jpost('/api/switch',{n:n});
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
const r=await jpost('/api/switch',id?{id:id}:{n:n});
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
toast('switched');
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
@@ -926,8 +927,47 @@ function populateSettings(){
}
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));
const lm = eaLoadMode();
set('s-loadmode', lm);
syncLoadModeSub(lm);
}
// Chargement du modèle. llama.cpp récent a REMPLACÉ --mlock / --no-mmap par
// --load-mode (auto, none, mmap, mlock, mmap+mlock, dio). L'éditeur écrit la
// forme moderne ; au lancement, loki serve la retraduit pour un moteur ancien
// (normalizeLoadFlags côté Go), et inversement.
function eaLoadMode(){
const lm = (eaGetValued('--load-mode') || eaGetValued('-lm')).toLowerCase();
if(lm) return lm === 'auto' ? '' : lm;
// Preset écrit avec les anciens drapeaux : même correspondance que le serveur.
const mlock = eaHasFlag('--mlock'), nommap = eaHasFlag('--no-mmap');
if(mlock && nommap) return 'mlock';
if(mlock) return 'mmap+mlock';
if(nommap) return 'none';
if(eaHasFlag('--mmap')) return 'mmap';
return '';
}
function eaSetLoadMode(mode){
const t = eaTokens().filter(x=>x!=='--mlock' && x!=='--no-mmap' && x!=='--mmap' && !x.startsWith('--load-mode='));
for(const f of ['--load-mode','-lm']){
const i = t.indexOf(f);
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
}
if(mode) t.push('--load-mode', mode);
eaSetTokens(t);
syncLoadModeSub(mode);
}
// Sous-titre : ce que fait le mode choisi (les options ne portent que son nom).
const LOAD_MODE_SUB = {
'': 'mmap, sauf si un périphérique ne le permet pas',
'mmap': 'fichier mappé, lu à la demande',
'none': 'tout charger, sans mmap',
'mlock': 'tout en RAM, verrouillé (sans mmap)',
'mmap+mlock': 'mappé et verrouillé en RAM',
'dio': 'lecture DirectIO si disponible',
};
function syncLoadModeSub(mode){
const el = document.getElementById('s-loadmode-sub');
if(el) el.textContent = LOAD_MODE_SUB[mode] ?? mode;
}
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
+50 -39
View File
@@ -35,15 +35,32 @@ addEventListener('DOMContentLoaded', ()=>{
if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c);
});
// iOS ANNULE un appui si la position de défilement change pendant qu'il a lieu.
// Le fil se recalant en bas à chaque token pendant la génération, presque chaque
// tap tombait dessus : plus rien ne répondait (pas même stop) jusqu'à la fin de
// la réponse. On suspend donc le suivi le temps du contact, et on rattrape juste
// après (TOUCH_GRACE). Repris d'AJEAN 0.15.7.
let TOUCHING = false, _touchEnd = 0;
const TOUCH_GRACE = 350;
(function(){
const on = ()=>{ TOUCHING = true; };
const off = ()=>{ TOUCHING = false; _touchEnd = performance.now(); if(stickyBottom) setTimeout(()=>scrollMaybe(), TOUCH_GRACE + 20); };
document.addEventListener('touchstart', on, {passive:true, capture:true});
document.addEventListener('touchend', off, {passive:true, capture:true});
document.addEventListener('touchcancel', off, {passive:true, capture:true});
})();
function scrollMaybe(){
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
if(REPLAYING) return;
if(stickyBottom){
if(stickyBottom && !TOUCHING && performance.now() - _touchEnd >= TOUCH_GRACE){
const c = chatEl();
c.scrollTop = c.scrollHeight;
const target = c.scrollHeight - c.clientHeight;
// N'écrire que si l'on n'y est pas déjà : sinon on relance pour rien la
// machinerie de défilement d'iOS, qui continue de voler les taps.
if(Math.abs(c.scrollTop - target) > 1) c.scrollTop = c.scrollHeight;
}
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
}
@@ -245,6 +262,9 @@ function hydrateImages(root){
}
});
}
// Lignes d'un contenu en cours d'écriture, comptées comme côté serveur : un saut
// de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
function bodyLineCount(s){ s=String(s).replace(/\r\n/g,'\n').replace(/\n$/,''); return s ? s.split('\n').length : 0; }
// Render a tool call as its own conversation message: the command the model
// wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){
@@ -286,16 +306,18 @@ function renderToolMsg(el, tu){
meta = meta || {lbl:'mémoire', head:'mémoire'};
let lbl = meta.lbl;
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
// Taille RÉELLE (result_chars) quand le flux n'a envoyé qu'un aperçu — sinon
// la bulle affichait toujours le même chiffre, celui du plafond d'aperçu.
const realChars = tu.result_chars || (tu.result ? tu.result.length : 0);
if(realChars){ lbl += ' · ~' + Math.max(1, Math.round(realChars/4)) + ' tok'; }
// Taille RÉELLE (result_chars) : le flux ne porte plus qu'un aperçu.
if(tu.result){ const n = tu.result_chars || tu.result.length; lbl += ' · ~' + Math.max(1, Math.round(n/4)) + ' tok'; }
setLabel(el, lbl);
// Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
// reporté sur l'étiquette, pour rester lisible bulle repliée.
// Les vrais totaux viennent du serveur (added/removed) : le diff lui-même est
// tronqué pour l'affichage, le recompter donnait « +120 » pour 500 lignes.
// Repli sur le décompte du diff pour les conversations d'avant ces champs.
let add=0, del=0;
if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
else if(tu.body){ add=tu.body.split('\n').length; }
if(tu.added!=null || tu.removed!=null){ add=tu.added||0; del=tu.removed||0; }
else if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
else if(tu.body){ add=bodyLineCount(tu.body); }
if(add||del) setLabelCounts(el, add, del);
const body=bodyOf(el); body.innerHTML='';
const head=document.createElement('div'); head.className='tool-head';
@@ -369,12 +391,9 @@ function renderToolMsg(el, tu){
body.appendChild(sub);
const pre=document.createElement('pre');
const code=document.createElement('code');
const preview=tu.result, rid=tu.result_id, PREVIEW=1600;
// Deux cas :
// - result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
// « voir plus » CHARGE le reste à la demande via l'API ;
// - sinon : le résultat est déjà complet ; s'il est long, « voir plus » le
// déplie localement (rien à charger).
const preview=tu.result, rid=tu.result_id;
// result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
// « voir plus » CHARGE le reste à la demande (AJEAN 0.15.1 / 0.15.4).
if(rid){
code.textContent=preview+'…';
pre.appendChild(code);
@@ -383,29 +402,19 @@ function renderToolMsg(el, tu){
let full=null, open=false;
more.onclick=async(e)=>{ e.stopPropagation();
if(full===null){
more.disabled=true; more.textContent='…';
// Résultat introuvable côté serveur (compacté, autre discussion) : on
// garde l'aperçu plutôt que de vider la bulle.
try{ const j=await jget('/api/chat/tool-result?id='+encodeURIComponent(rid)); full=(j&&j.result)||preview; }
catch(_){ full=preview; }
more.disabled=false;
// Un échec n'est PAS mémorisé : on prévient et on laisse réessayer.
more.disabled=true; const prevTxt=more.textContent; more.textContent='…';
let got=null;
try{ const r=await jfetch('/api/chat/tool-result?id='+encodeURIComponent(rid)); const j=await r.json(); if(r.ok && j && typeof j.result==='string') got=j.result; }
catch(_){}
more.disabled=false; more.textContent=prevTxt;
if(got===null){ toast('résultat complet indisponible'); return; }
full=got;
}
open=!open;
code.textContent = open ? full : preview+'…';
pre.classList.toggle('expanded', open);
more.textContent = open ? 'voir moins' : 'voir plus';
};
body.appendChild(toolBlock(pre, more));
} else if(preview.length>PREVIEW){
code.textContent=preview.slice(0,PREVIEW)+'…';
pre.appendChild(code);
const more=document.createElement('button');
more.className='tool-more'; more.type='button'; more.textContent='voir plus';
let open=false;
more.onclick=(e)=>{ e.stopPropagation(); open=!open;
code.textContent = open ? preview : preview.slice(0,PREVIEW)+'…';
pre.classList.toggle('expanded', open);
more.textContent = open ? 'voir moins' : 'voir plus';
more.textContent = open ? 'réduire' : 'voir plus';
};
body.appendChild(toolBlock(pre, more));
} else {
@@ -424,7 +433,7 @@ function renderToolMsg(el, tu){
}
// Bloc d'un résultat d'outil : le pre scrolle (max-height), donc la barre
// d'actions vit sur un bloc-parent NON scrollant, ancrée en bas à droite — elle
// reste au coin quoi qu'on scrolle dans le résultat. La barre réunit « voir
// reste au coin même quand on scrolle dans le résultat. Elle réunit « voir
// plus » (passé ici) et, ajouté ensuite par addCopyButtons, « copier ».
function toolBlock(pre, more){
const block=document.createElement('div'); block.className='tool-block';
@@ -437,9 +446,8 @@ function toolBlock(pre, more){
// Inject a "copier" button into every <pre> code block (idempotent).
function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{
// Résultat d'outil : le pre est enrobé dans .tool-block et la barre
// d'actions est SŒUR du pre (pas dedans) ; on cherche donc le copier dans
// ce périmètre.
// Résultat d'outil : la barre d'actions est SŒUR du pre (pas dedans) ; on
// cherche donc le copier dans le bloc qui les réunit.
const scope = pre.closest('.tool-block') || pre;
if(scope.querySelector('.copybtn')) return;
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
@@ -454,8 +462,8 @@ function addCopyButtons(root){
btn.textContent='copié ✓'; btn.classList.add('done');
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
};
// S'il y a une barre d'actions (résultat d'outil avec « voir plus »), le
// bouton copier s'y range à côté ; sinon il se colle en bas à droite du bloc.
// Barre d'actions (résultat d'outil avec « voir plus ») : le copier s'y
// range à côté ; sinon il se colle en bas à droite du bloc.
(scope.querySelector('.tool-actions')||pre).appendChild(btn);
});
}
@@ -564,6 +572,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(
function autoGrow(ta){
ta = ta || document.getElementById('input');
if(!ta) return;
// Du texte à envoyer ? Pendant une réponse, c'est ce qui fait réapparaître
// « envoyer » à côté de stop (message mis en file, voir send()).
if(ta.id==='input') document.documentElement.setAttribute('data-hastext', ta.value.trim() ? '1' : '0');
ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
+136 -51
View File
@@ -4,10 +4,39 @@
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
let lastSeq=0, streamAbort=null;
// REPLAY_FULL : demander le journal ENTIER au prochain abonnement. Le serveur
// ne rejoue par défaut que la fin d'une longue discussion (voir replayTailMax) ;
// le bandeau « charger le début » rouvre le flux avec ce drapeau.
let REPLAY_FULL=false;
// Historique paginé (AJEAN 0.15.7) : au chargement, le serveur ne rejoue que les
// HIST_TAIL derniers échanges et annonce le reste ({history_more: N}). Le bouton
// en haut du fil redemande alors un rejeu complet (HIST_FULL) en gardant la
// position de lecture (HIST_RESTORE = distance au bas du fil, réappliquée au
// caught_up).
const HIST_TAIL=20;
let HIST_FULL=false, HIST_RESTORE=null;
// Discussion AFFICHÉE (id reçu au caught_up/reset). Renvoyée à chaque
// (ré)abonnement : si un autre appareil a changé de discussion pendant une
// coupure, le serveur ordonne un reset au lieu de greffer le nouveau fil sur
// l'ancien (AJEAN 0.14.0).
let CONV_ID='';
// File d'attente (AJEAN 0.14.0) : messages envoyés PENDANT une réponse, montrés
// en gris au-dessus de la carte jusqu'à ce que le flux les confirme (delta
// `user` du même texte) — injectés en cours de réponse ou au tour suivant.
function queueAdd(text){
const box=document.getElementById('queue-list'); if(!box) return null;
const el=document.createElement('div'); el.className='queued-msg'; el.textContent=text||'(pièce jointe)';
el.dataset.text=text;
box.appendChild(el); box.classList.add('show');
return el;
}
function queueSync(){ const box=document.getElementById('queue-list'); if(box) box.classList.toggle('show', !!box.children.length); }
function queueTake(text){
const box=document.getElementById('queue-list'); if(!box) return;
const el=[...box.children].find(e=>e.dataset.text===text);
if(el) el.remove();
queueSync();
}
function queueClear(){ const box=document.getElementById('queue-list'); if(box) box.textContent=''; queueSync(); }
// Identifiant d'envoi, stable d'un réessai à l'autre : le serveur ne met pas
// deux fois le même message en file si la réponse s'est perdue en route.
function newCID(){ try{ return crypto.randomUUID(); }catch(_){ return Date.now().toString(36)+Math.random().toString(36).slice(2); } }
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
@@ -208,7 +237,8 @@ function renderStats(el, s){
if(!el||!s) return;
const parts=[];
const pt=s.prompt_tokens||s.prompt_tokens_total;
if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
// Débit de lecture inconnu (API tierce sans timings) : on n'affiche que la taille.
if(pt) parts.push('prefill '+pt+' tok'+(s.prompt_per_second ? ' · '+s.prompt_per_second.toFixed(0)+' tok/s' : ''));
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
if(!parts.length) return;
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
@@ -407,54 +437,38 @@ function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoot
// bulle d'outil) doit voir le texte complet.
function settleBlocks(){ smoothSnap(); flushRender(); }
// showReplayBanner affiche, en tête du fil, le bandeau « début masqué ». Un clic
// coupe le flux et se réabonne depuis zéro en demandant TOUT le journal.
function showReplayBanner(hidden){
const c=chatEl(); if(!c) return;
let b=document.getElementById('replay-banner');
if(!b){
b=document.createElement('div');
b.id='replay-banner'; b.className='replay-banner';
c.insertBefore(b, c.firstChild);
}
b.textContent='';
const txt=document.createElement('span');
txt.textContent='Début de la discussion masqué ('+hidden+' événements) — ';
const btn=document.createElement('button');
btn.type='button'; btn.className='replay-more'; btn.textContent='charger le début';
btn.onclick=()=>{ btn.disabled=true; btn.textContent='chargement…'; loadFullReplay(); };
b.appendChild(txt); b.appendChild(btn);
}
// loadFullReplay relance l'abonnement depuis le début, journal entier. On repart
// d'un fil VIDE : rejouer par-dessus l'existant ferait doublon.
function loadFullReplay(){
REPLAY_FULL=true; lastSeq=0; REPLAYING=true;
if(streamAbort) streamAbort.abort();
const c=chatEl(); if(c){ c.innerHTML=''; c.style.opacity='0'; }
PENDING=null; newTurn();
setChatLoading('chargement du début de la conversation…');
connectStream();
// Rafraîchissement groupé de la liste des discussions (plusieurs événements
// rapprochés = un seul appel).
let histRefreshTimer=null;
function histRefreshSoon(){
if(typeof loadConversations!=='function') return;
clearTimeout(histRefreshTimer);
histRefreshTimer=setTimeout(()=>{ histRefreshTimer=null; loadConversations(); }, 400);
}
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique.
function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
// Replay borné : le serveur a sauté le début d'une longue discussion. On
// l'annonce en tête du fil, avec le bouton qui va le chercher. Rien n'est
// perdu : le journal complet est sur le disque.
if(typeof d.replay_truncated==='number'){ showReplayBanner(d.replay_truncated); return; }
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
if(d.caught_up){
if(typeof d.id==='string') CONV_ID=d.id;
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
if(REPLAYING && HIST_RESTORE!==null){
// Historique complet demandé : on garde la position de lecture (distance au
// bas du fil) au lieu de sauter en bas, et on la recale une fois la mise en
// page stabilisée (le rejeu pose ses blocs de façon différée).
REPLAYING=false; syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1';
const keep=HIST_RESTORE; HIST_RESTORE=null; stickyBottom=false;
const put=()=>{ c.scrollTop=Math.max(0, c.scrollHeight-keep); };
put(); requestAnimationFrame(()=>requestAnimationFrame(put)); setTimeout(put, 80); setTimeout(put, 250);
} else if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
@@ -467,7 +481,9 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; }
if(d.queue_dropped!==undefined){ if(!REPLAYING){ queueClear(); toast('messages en attente abandonnés'); } return; }
if(d.reset!==undefined){ if(typeof d.id==='string') CONV_ID=d.id; queueClear(); HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -484,6 +500,10 @@ function handleDelta(d){
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
// bulle de réponse du tour.
T.model = d.model || '';
if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte
// Le serveur a déjà enregistré la discussion (StartTurn persiste) : la liste
// la montre dès le premier message, sans attendre la fin de la réponse.
if(!REPLAYING) histRefreshSoon();
let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
@@ -552,6 +572,17 @@ function handleDelta(d){
return; }
if(d.reasoning_content){
killTyping('reasoning');
// Raisonnement qui reprend APRÈS du texte de réponse (modèle qui repense en
// cours de réponse, reprise après coupure) : il allait dans l'ANCIENNE bulle,
// déjà repliée au-dessus de la réponse. On en ouvre une nouvelle sous la
// réponse : l'ordre affiché suit l'ordre reçu (AJEAN 0.17.4).
// Réponse encore vide (simple saut de ligne avant la réflexion) : on retire
// la bulle vide et le raisonnement continue dans la sienne.
if(T.contentEl){
if((T.fullContent||'').trim()){ settleBlocks(); T.reasonEl=null; }
else { smoothReset(); cancelRender(); T.contentEl.remove(); }
T.contentEl=null;
}
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
@@ -595,7 +626,7 @@ async function connectStream(){
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController();
try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq, full:REPLAY_FULL}),signal:streamAbort.signal});
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL,conv_id:CONV_ID}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
@@ -620,24 +651,71 @@ async function connectStream(){
}
// Interrompt la génération en cours côté serveur (la goroutine détachée est
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
// _stopAt : anti-doublon, le bouton réagit à l'APPUI sur écran tactile (voir plus
// bas) puis le clic qui suit éventuellement ne doit pas relancer.
let _stopAt = 0;
function stopGen(){
const now = Date.now(); if(now - _stopAt < 800) return; _stopAt = now;
jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop');
}
// Sur écran tactile, stop agit dès l'appui (pointerdown) : le « clic » complet
// peut être annulé par iOS si le fil défile au même moment — or c'est justement
// quand l'IA écrit qu'on veut l'arrêter vite. Repris d'AJEAN 0.15.7.
document.addEventListener('DOMContentLoaded', ()=>{
const st=document.getElementById('stop'); if(!st) return;
st.addEventListener('pointerdown', (e)=>{ if(e.pointerType!=='mouse'){ e.preventDefault(); stopGen(); } });
});
// Bouton « échanges précédents » posé en tête du fil quand le serveur n'a rejoué
// que la fin de la conversation.
function showHistoryMore(n){
const chat=chatEl(); if(!chat || !n) return;
let box=chat.querySelector('.history-more');
if(!box){ box=document.createElement('div'); box.className='history-more'; chat.insertBefore(box, chat.firstChild); }
box.innerHTML='';
const b=document.createElement('button'); b.type='button';
b.textContent = n===1 ? 'afficher l\'échange précédent' : 'afficher les '+n+' échanges précédents';
b.onclick=loadFullHistory;
box.appendChild(b);
}
// Rejeu COMPLET de la conversation vive, en gardant ce qu'on lisait à l'écran :
// on vide le fil, on coupe le flux, et la boucle connectStream se reconnecte
// depuis le début sans pagination (tail=0).
function loadFullHistory(){
const chat=chatEl(); if(!chat) return;
const box=chat.querySelector('.history-more'); if(box){ const b=box.querySelector('button'); if(b){ b.disabled=true; b.textContent='chargement…'; } }
HIST_RESTORE=chat.scrollHeight-chat.scrollTop;
HIST_FULL=true;
chat.innerHTML=''; newTurn();
smoothReset(); cancelRender();
lastSeq=0; REPLAYING=true;
if(streamAbort){ try{ streamAbort.abort(); }catch(e){} }
}
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
// « network error » alarmiste alors que l'IA répond quand même.
// Fuseau du navigateur, envoyé avec chaque message : le conteneur tourne en UTC,
// et « rappelle-moi à 17h » doit tomber à 17h ici (voir rememberUserTZ).
const USER_TZ=(()=>{ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||''; }catch(_){ return ''; } })();
async function send(){
if(busy) return;
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim();
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
if(!text && !ATTACH.length) return;
ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
addPending(text);
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
// Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en
// compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche
// en attente au-dessus de la carte, pas dans le fil qui s'écrit encore.
const queuedAtSend = busy;
let qel=null;
// Sinon le message s'affiche TOUT DE SUITE dans le fil, en gris : il ne
// disparaît plus le temps de l'aller-retour. Il s'éclaircit quand le flux le
// confirme (confirmPending).
if(queuedAtSend) qel=queueAdd(text); else addPending(text);
const fail=(m)=>{ if(qel){ qel.remove(); queueSync(); } else clearPending(); toast(m); ta.value=text; autoGrow(ta); };
const cid=newCID();
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
// on doit pouvoir en enlever une, et un échec doit rester visible.
@@ -645,13 +723,20 @@ async function send(){
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
// Les pastilles passent dans la bulle en attente : le message porte ses
// fichiers dès l'envoi, sans attendre l'aller-retour.
if(PENDING) addMsgFiles(PENDING, attachSent());
if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){
try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
if(r.status===409 || r.ok) clearAttach();
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
if(r.ok) return; // la bulle + les tokens arrivent par le flux
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid,tz:USER_TZ})});
if(r.ok) clearAttach();
// 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
if(r.ok){
// Parti tout de suite alors qu'on le croyait en file (le tour venait de
// finir) : la pastille disparaît, la bulle arrive par le flux.
let j={}; try{ j=await r.json(); }catch(_){}
if(qel && j.queued===false){ qel.remove(); queueSync(); }
return; // la bulle + les tokens arrivent par le flux
}
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
}catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600));
+6 -1
View File
@@ -27,6 +27,7 @@ async function openExternal(id){
document.getElementById('ext-test-out').textContent = '';
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
document.getElementById('ext-vision').checked = false;
showModal('ext-modal');
if(!id) return;
let d = {};
@@ -34,6 +35,7 @@ async function openExternal(id){
if(extEditing !== id) return; // une autre ouverture a pris la main
set('ext-name', d.name || ''); set('ext-url', d.url || '');
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
document.getElementById('ext-vision').checked = !!d.vision;
if(d.hasKey){
document.getElementById('ext-key-sub').textContent =
'une clé est enregistrée — laisse vide pour la conserver';
@@ -50,6 +52,7 @@ function extPayload(){
key: document.getElementById('ext-key').value || '',
ctx: (document.getElementById('ext-ctx').value || '').trim(),
keyTouched: extKeyTouched,
vision: document.getElementById('ext-vision').checked,
};
}
@@ -79,9 +82,11 @@ async function saveExternal(){
if(!p.model){ toast('modèle requis'); return; }
const r = await jpost('/api/preset/external/save', p);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('enregistré');
// Preset en service : le serveur l'a appliqué aussitôt (applied).
toast(r.applied ? 'enregistré et appliqué' : 'enregistré');
closeExternal();
loadPresets();
if(r.applied && typeof loadStatus === 'function') loadStatus();
}
// Même route de suppression que n'importe quel preset — et même refus si c'est
+29 -1
View File
@@ -19,6 +19,12 @@ let CONVS = [], CONV_ACTIVE = '';
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
// route, ou ouverte depuis un autre appareil).
let CONV_BUSY = false;
// Liste paginée (AJEAN 0.15.6) : avec des centaines de discussions, construire
// toutes les lignes d'un coup figeait l'ouverture du panneau. On en dessine
// CONV_PAGE, puis la suite quand le bas de la liste devient visible. La
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
async function loadConversations(){
let r;
@@ -41,6 +47,7 @@ function renderConversations(){
if(!cont) return;
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
@@ -53,7 +60,10 @@ function renderConversations(){
convSyncBusy();
return;
}
for(const c of list){
// La discussion active reste toujours visible, même loin dans la liste.
const ai = list.findIndex(c => c.id === CONV_ACTIVE);
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
@@ -91,10 +101,28 @@ function renderConversations(){
row.append(info, when, acts);
cont.appendChild(row);
}
if(list.length > shown) convMoreRow(cont, list.length - shown);
syncTopbarTitle();
convSyncBusy();
}
// Ligne « N de plus » en bas de la liste : un clic, ou son arrivée à l'écran
// (IntersectionObserver), dessine la page suivante.
function convMoreRow(cont, left){
const more = document.createElement('button');
more.type = 'button';
more.className = 'conv-more';
more.textContent = 'afficher ' + Math.min(left, CONV_PAGE) + ' de plus (' + left + ' restantes)';
const next = () => { CONV_SHOWN += CONV_PAGE; renderConversations(); };
more.onclick = next;
cont.appendChild(more);
if(convMoreObs) convMoreObs.disconnect();
if(window.IntersectionObserver){
convMoreObs = new IntersectionObserver(es => { if(es.some(e => e.isIntersecting)){ convMoreObs.disconnect(); next(); } });
convMoreObs.observe(more);
}
}
// --- Discussion au travail : anneau qui tourne ------------------------------
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
+2 -1
View File
@@ -65,7 +65,7 @@ function onModelSwitch(sel){
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
// pendant les quelques secondes du redémarrage.
switchTo(n, name);
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
}
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
@@ -143,6 +143,7 @@ function paintModelSwitch(){
MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.dataset.id = p.id || '';
o.textContent = p.name;
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
g.appendChild(o);
+1 -1
View File
@@ -110,7 +110,7 @@ function renderCriteria(list){
for(const c of list){
const li = document.createElement('li');
li.className = 'crit-'+(c.status||'pending');
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : (c.status==='completed' ? '◐' : '○'));
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
ul.appendChild(li);
}
+32 -17
View File
@@ -443,29 +443,19 @@ button:disabled{opacity:.5;cursor:not-allowed}
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
.diff-count .a{color:var(--add)}
.diff-count .d{color:var(--del)}
/* Bandeau « début de la discussion masqué » : le serveur ne rejoue que la fin
d'un très long fil (replayTailMax). Discret, en tête du fil, avec le bouton
qui va chercher le reste. */
.replay-banner{margin:4px auto 10px;padding:6px 10px;font-size:11px;color:var(--dim);background:var(--panel);border:1px solid var(--border);border-radius:3px;text-align:center;max-width:680px}
.replay-banner .replay-more{padding:2px 8px;font-size:11px;background:none;border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer}
.replay-banner .replay-more:hover{color:var(--accent);border-color:var(--accent)}
.replay-banner .replay-more:disabled{cursor:default;opacity:.6}
.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
/* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre
ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre
scrolle (max-height), donc la barre est portée par le bloc-parent NON
scrollant (.tool-block) : elle reste au coin quoi qu'on scrolle. */
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
/* Résultat d'outil : « voir plus » + « copier » réunis dans une barre ancrée en
bas à droite. Le pre scrolle (max-height), donc la barre est portée par le
bloc-parent NON scrollant (.tool-block) : elle reste au coin. Déplié, le pre
lève son plafond pour montrer VRAIMENT plus (AJEAN 0.15.4). */
.msg.tool .tool-block{position:relative}
.msg.tool .tool-block .tool-actions{position:absolute;bottom:7px;right:7px;display:flex;gap:6px;align-items:center;z-index:1}
.msg.tool .tool-actions .copybtn{position:static;bottom:auto;right:auto}
.msg.tool .tool-more{padding:3px 8px;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;opacity:.5;transition:opacity .15s,color .15s,border-color .15s}
.msg.tool .tool-actions .tool-more{margin:0;padding:3px 8px;font:inherit;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;cursor:pointer;transition:opacity .15s,color .15s,border-color .15s}
.msg.tool .tool-block:hover .tool-actions .tool-more,.msg.tool .tool-block:hover .tool-actions .copybtn{opacity:1}
.msg.tool .tool-more:hover{border-color:var(--accent);color:var(--accent)}
.msg.tool .tool-more:disabled{cursor:default}
/* Déplié : on lève le plafond de hauteur pour voir VRAIMENT plus, au lieu de
rester enfermé dans une fenêtre de 280 px qu'il faut scroller. */
.msg.tool .tool-actions .tool-more:hover{border-color:var(--accent);color:var(--accent)}
.msg.tool pre.expanded{max-height:none}
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
.msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
@@ -1220,6 +1210,19 @@ html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
#attach-list::-webkit-scrollbar{display:none}
#attach-list.show{display:flex}
#composer:has(#attach-list.show){--fade:6px}
/* File d'attente : messages envoyés pendant une réponse, pas encore pris en
compte. Même bande que les pièces jointes, en gris. */
#queue-list{order:0;display:none;flex-direction:column;align-items:flex-end;gap:4px;margin:0 var(--col);padding:0 2px 8px}
#queue-list.show{display:flex}
#composer:has(#queue-list.show){--fade:6px}
#queue-list .queued-msg{max-width:80%;padding:5px 12px;border-radius:12px;background:var(--panel);
border:1px dashed var(--border);color:var(--dim);font-size:12px;line-height:1.35;
white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
box-shadow:0 2px 10px rgba(0,0,0,.10)}
#queue-list .queued-msg::before{content:"en attente · ";font-style:italic;opacity:.8}
/* Pendant une réponse, envoyer reste possible dès qu'il y a du texte : le
message part en file (voir send()). Stop reste à côté. */
html[data-busy="1"][data-hastext="1"] #send{display:inline-flex}
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
cadre, sinon le texte de la conversation transparaît derrière. */
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
@@ -1711,6 +1714,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
border:0;padding:8px 11px;text-align:left;cursor:pointer}
.conv-more:hover{color:var(--text)}
/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
agent est en train de travailler, y compris depuis un autre appareil. */
@@ -2102,6 +2108,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
#criteria-list li.crit-passed{color:var(--accent)}
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
#criteria-list li.crit-pending{color:var(--dim)}
#criteria-list li.crit-completed{color:var(--text)}
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
@@ -2207,3 +2214,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
.md-report table{border-collapse:collapse;margin:.5em 0}
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
/* Historique paginé : bouton « échanges précédents » en tête du fil. */
#chat .history-more{display:flex;justify-content:center;padding:6px 0 14px}
#chat .history-more button{font:inherit;font-size:12px;color:var(--dim);background:var(--row-bg);
border:1px solid var(--border);border-radius:999px;padding:6px 14px;cursor:pointer;
transition:background .15s,color .15s,border-color .15s}
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
#chat .history-more button:disabled{opacity:.6;cursor:default}
+54 -24
View File
@@ -80,6 +80,9 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
"load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon
"load_pct": loadPct, // % estimé du chargement en cours (-1 = non mesurable)
"external": external, // preset externe : le chat part vers une API distante
// id du preset actif : un autre appareil a pu basculer, l'UI se
// resynchronise sans reload (voir loadStatus). AJEAN 0.13.6.
"preset": activePresetID(),
})
}
@@ -399,7 +402,8 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{}
for _, p := range list {
item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active}
if content, err := ReadPreset(p.ID); err == nil {
content, _ := ReadPreset(p.ID)
if content != "" {
// Preset externe : ni quant, ni raisonnement local à annoncer — on
// l'étiquette et on donne le nom du modèle distant à afficher.
if cfg := parseEnv(content); isExternalConfig(cfg) {
@@ -417,7 +421,7 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
item["reasoning"] = strings.ToLower(r)
}
}
if sb, ok := store[p.ID]; ok {
if sb, ok := store[p.ID]; ok && benchMatchesPreset(sb, parseEnv(content)) {
item["bench"] = map[string]any{
"prefill": sb.Result.PromptPerSecond,
"decode": sb.Result.PredictedPerSec,
@@ -548,16 +552,8 @@ func handleMemoryMode(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&req)
// On normalise via memMode() en réinjectant la valeur : toute entrée
// inconnue retombe sur "always", donc on valide en passant par le parseur.
m := MemAlways
switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) {
case MemOff:
m = MemOff
case MemOnDemand:
m = MemOnDemand
case MemAlways:
m = MemAlways
}
if err := setMemMode(m); err != nil {
// Réglage du PROJET ACTIF (AJEAN 0.13.12).
if err := setMemMode(normalizeMemMode(req.Mode)); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
@@ -761,7 +757,10 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
return
}
setMemDEK(dek)
resumeMemMigration() // si une migration attendait le déverrouillage
resumeMemMigration() // si une migration attendait le déverrouillage
// AVANT le rechargement : la discussion active est désignée par une clé
// qu'une version précédente chiffrait par erreur (voir plainStoreKey).
healPlainStoreKeys()
reloadEncryptedStores() // recharge la discussion chiffrée en RAM
// Migration douce : chiffre ce qui serait resté en clair (discussions,
// blocs archivés, trackers). Idempotent — n'encode que ce qui ne l'est pas.
@@ -1223,8 +1222,14 @@ func handleModelUse(w http.ResponseWriter, r *http.Request) {
}
func handleSwitch(w http.ResponseWriter, r *http.Request) {
// id = identité stable du preset (nom de fichier). Le numéro seul est
// dangereux : si la liste a changé depuis l'affichage (réordonnée, preset
// ajouté ou supprimé depuis un autre appareil), la même position désigne un
// AUTRE preset, et on chargeait le mauvais (AJEAN 0.17.4). n reste accepté
// pour les anciens clients.
var req struct {
N int `json:"n"`
N int `json:"n"`
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
@@ -1235,11 +1240,26 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
if req.N < 1 || req.N > len(list) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
return
var target Preset
if id := strings.TrimSpace(req.ID); id != "" {
found := false
for _, p := range list {
if p.ID == id {
target, found = p, true
break
}
}
if !found {
sendJSON(w, 404, map[string]any{"ok": false, "error": "preset introuvable (supprimé ou renommé ?)"})
return
}
} else {
if req.N < 1 || req.N > len(list) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
return
}
target = list[req.N-1]
}
target := list[req.N-1]
// On écrit config.env TOUT DE SUITE (c'est lui qui décide du preset actif),
// puis on répond — le redémarrage du service part en arrière-plan. Passer par
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
@@ -1336,6 +1356,9 @@ func handleBenchLast(w http.ResponseWriter, r *http.Request) {
// le décodeur JSON ignore les champs qu'il ne connaît pas.
type chatReq struct {
Temperature float64 `json:"temperature"`
// TZ = fuseau IANA du navigateur (« Europe/Paris »), retenu pour dater ce
// que le modèle planifie. Voir rememberUserTZ.
TZ string `json:"tz"`
// Surcharges par requête, portées par les agents ajean.link qui ont leurs
// propres interrupteurs. nil = on prend la configuration de la machine.
// ⚠️ Elles ne peuvent que RESTREINDRE, jamais rallumer (voir capsFromBody).
@@ -1349,12 +1372,19 @@ type chatReq struct {
// vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct).
Message string `json:"message"`
From int `json:"from"`
// Full = rejouer TOUT le journal de la discussion. Par défaut le replay est
// BORNÉ à sa fin (voir replayTailMax) : une discussion de plusieurs centaines
// de tours mettait des secondes à s'afficher et laissait autant de bulles
// dans le DOM. Le bouton « charger le début » de l'interface se réabonne
// avec full=true.
Full bool `json:"full"`
// Tail (flux d'abonnement) : nombre de derniers ÉCHANGES à rejouer au
// chargement ; 0 = tout. nil = ancien client (app relais…), qui ne sait pas
// afficher le bouton « échanges précédents » : il reçoit toujours tout.
Tail *int `json:"tail"`
// ClientID = identifiant unique d'un ENVOI (/api/chat/send), stable d'un
// réessai à l'autre : le serveur ne met pas deux fois le même message en
// file (voir Conversation.recentCIDs).
ClientID string `json:"cid"`
// ConvID = id de la discussion AFFICHÉE par le client (reçu au dernier
// caught_up/reset du flux). Renvoyé à chaque (re)abonnement pour que le
// serveur détecte qu'un AUTRE appareil a changé de discussion entre-temps
// et ordonne un reset avant de rejouer — sinon deux fils fusionnaient.
ConvID string `json:"conv_id"`
// Files = chemins relatifs des fichiers déposés juste avant par
// /api/chat/upload ("uploads/rapport.pdf"). Ils sont annoncés au modèle en
// tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et
+75
View File
@@ -5,7 +5,10 @@ import (
"crypto/subtle"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
)
@@ -46,6 +49,10 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
map[string]any{"error": "configuration illisible — réessaie dans un instant"})
return
}
if msg := crossSiteReject(r, key != ""); msg != "" {
sendJSON(w, http.StatusForbidden, map[string]any{"error": msg})
return
}
if key == "" {
next(w, r)
return
@@ -166,3 +173,71 @@ func cmdSetWebKey(args []string) error {
fmt.Printf(" (relance 'loki web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
return nil
}
// crossSiteReject refuse les requêtes qu'un SITE TIERS ouvert dans le navigateur
// (de la machine ou du réseau local) ferait en douce vers l'API. Sans clé (le
// défaut), l'API est ouverte : une page malveillante pouvait envoyer un POST
// « simple » (text/plain, sans pré-vérification CORS) à <serveur>:8090 et
// piloter Loki, jusqu'à faire exécuter des commandes à l'agent. Renvoie "" si
// la requête est acceptable, sinon la raison du refus. Repris d'AJEAN 0.15.5.
//
// - Sec-Fetch-Site: cross-site → refus (navigateurs récents, couvre aussi les
// GET déclenchés par une balise <img>/<form>).
// - Origin présent et différent de l'hôte appelé → refus (tous les navigateurs
// envoient Origin sur un POST cross-origin). Les clients hors navigateur
// (scripts, curl, apps) n'envoient pas d'Origin : non concernés.
// - Sans clé seulement : l'hôte appelé doit être local (IP, localhost, nom de
// la machine, nom sans point, .local/.lan…) ou listé dans
// LOKI_TRUSTED_HOSTS. Bloque le « DNS rebinding », où un domaine malveillant
// se fait résoudre en IP locale pour paraître même-origine. Avec une clé,
// inutile : le navigateur n'envoie jamais le Bearer tout seul. Le trafic du
// tunnel (authentifié par le relais) est lui aussi dispensé de ce contrôle.
func crossSiteReject(r *http.Request, keyed bool) string {
if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") {
return "requête d'un site tiers refusée"
}
if o := r.Header.Get("Origin"); o != "" && o != "null" {
u, err := url.Parse(o)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
return "origine non autorisée : " + o
}
} else if o == "null" {
return "origine non autorisée"
}
if !keyed && r.Header.Get(viaTunnelHeader) != "tunnel" && !localHostName(r.Host) {
return "hôte « " + r.Host + " » non autorisé sans clé de pilotage — définis-en une (loki set-web-key) ou ajoute ce nom à LOKI_TRUSTED_HOSTS"
}
return ""
}
// localHostName : l'hôte désigne-t-il la machine ou le réseau local (et non un
// domaine public, seul utilisable pour un DNS rebinding) ?
func localHostName(hostport string) bool {
h := hostport
if hh, _, err := net.SplitHostPort(hostport); err == nil {
h = hh
}
h = strings.ToLower(strings.Trim(h, "[]."))
if h == "" || net.ParseIP(h) != nil || !strings.Contains(h, ".") {
return true
}
for _, suf := range []string{".localhost", ".local", ".lan", ".home", ".internal", ".home.arpa", ".localdomain"} {
if strings.HasSuffix(h, suf) {
return true
}
}
if me, err := os.Hostname(); err == nil && me != "" {
me = strings.ToLower(me)
if h == me || strings.HasPrefix(h, me+".") {
return true
}
}
// Échappatoire du conteneur : derrière un reverse proxy (loki.mondomaine.fr),
// le nom public n'est ni local ni celui du conteneur.
for _, t := range strings.Split(os.Getenv("LOKI_TRUSTED_HOSTS"), ",") {
if t = strings.ToLower(strings.TrimSpace(t)); t != "" && h == t {
return true
}
}
return false
}
+46
View File
@@ -0,0 +1,46 @@
package loki
import (
"net/http/httptest"
"testing"
)
func TestCrossSiteReject(t *testing.T) {
t.Setenv("LOKI_TRUSTED_HOSTS", "loki.exemple.fr")
cas := []struct {
nom string
host, origin string
fetchSite string
tunnel bool
keyed bool
refuse bool
}{
{nom: "curl sur IP", host: "192.168.1.10:8090"},
{nom: "localhost", host: "localhost:8090", origin: "http://localhost:8090"},
{nom: "nom sans point", host: "tower:8090", origin: "http://tower:8090"},
{nom: "nom .local", host: "tower.local:8090"},
{nom: "origine tierce", host: "192.168.1.10:8090", origin: "http://evil.example", refuse: true},
{nom: "origine null", host: "localhost:8090", origin: "null", refuse: true},
{nom: "sec-fetch cross-site", host: "localhost:8090", fetchSite: "cross-site", refuse: true},
{nom: "rebinding sans clé", host: "evil.example:8090", origin: "http://evil.example:8090", refuse: true},
{nom: "domaine avec clé", host: "evil.example:8090", keyed: true},
{nom: "domaine de confiance", host: "loki.exemple.fr", origin: "https://loki.exemple.fr"},
{nom: "tunnel", host: "relais.exemple.net", tunnel: true},
}
for _, c := range cas {
r := httptest.NewRequest("POST", "/api/status", nil)
r.Host = c.host
if c.origin != "" {
r.Header.Set("Origin", c.origin)
}
if c.fetchSite != "" {
r.Header.Set("Sec-Fetch-Site", c.fetchSite)
}
if c.tunnel {
r.Header.Set(viaTunnelHeader, "tunnel")
}
if got := crossSiteReject(r, c.keyed) != ""; got != c.refuse {
t.Errorf("%s : refus=%v, attendu %v", c.nom, got, c.refuse)
}
}
}
+34 -33
View File
@@ -84,7 +84,11 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
// goroutine détachée — fermer le navigateur n'arrête donc plus rien. Partagé par
// handleChat (clair) et handleE2EChat (chiffré).
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
conv.Subscribe(ctx, body.From, body.Full, emit)
tail := -1
if body.Tail != nil {
tail = *body.Tail
}
conv.SubscribeTail(ctx, body.From, tail, body.ConvID, emit)
}
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse
@@ -102,8 +106,18 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"})
return
}
if err := conv.StartTurn(body.Message, files, capsFromBody(body), body.Temperature); err != nil {
// 409 = occupé (génération en cours) ; 503 = modèle pas prêt.
rememberUserTZ(body.TZ)
// Génération en cours : au lieu de refuser, on MET EN FILE (AJEAN 0.14.0).
// Le message est injecté dans la réponse en cours à la prochaine frontière
// d'étape, ou traité comme tour suivant. queued=true le signale au client.
queued, err := conv.EnqueueOrStart(body.ClientID, body.Message, files, capsFromBody(body), body.Temperature)
if err == ErrDupSend {
// Réessai d'un envoi déjà accepté (réponse perdue en route) : succès.
sendJSON(w, 200, map[string]any{"ok": true, "dup": true})
return
}
if err != nil {
// 409 = une tâche planifiée occupe le modèle ; 503 = modèle pas prêt.
code := 503
if err == ErrBusy {
code = 409
@@ -112,7 +126,23 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
sendJSON(w, code, 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, "queued": queued})
}
// handleToolResult renvoie le résultat COMPLET d'un outil dont le flux n'a
// transporté qu'un aperçu (bouton « voir plus », voir tool_results.go).
func handleToolResult(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
sendJSON(w, 400, map[string]any{"error": "id manquant"})
return
}
s, ok := loadToolResult(id)
if !ok {
sendJSON(w, 404, map[string]any{"error": "résultat non disponible"})
return
}
sendJSON(w, 200, map[string]any{"result": s})
}
func handleChatStop(w http.ResponseWriter, r *http.Request) {
@@ -202,35 +232,6 @@ func handleChatState(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, conv.state())
}
// handleToolResult renvoie le résultat COMPLET d'un appel d'outil, chargé à la
// demande par le bouton « voir plus » de la bulle (le flux ne transporte qu'un
// aperçu). Le résultat complet vit dans la vue « modèle » (conv.Messages), repéré
// par son tool_call_id. Absent (compacté, archivé, discussion changée) → 404, et
// l'UI garde son aperçu plutôt que d'afficher une erreur.
func handleToolResult(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
sendJSON(w, 400, map[string]any{"error": "id manquant"})
return
}
conv.mu.Lock()
found, ok := "", false
for _, m := range conv.Messages {
if m.Role == "tool" && m.ToolCallID == id {
if s, isStr := m.Content.(string); isStr {
found, ok = s, true
}
break
}
}
conv.mu.Unlock()
if !ok {
sendJSON(w, 404, map[string]any{"error": "résultat non disponible"})
return
}
sendJSON(w, 200, map[string]any{"result": found})
}
func handleChat(w http.ResponseWriter, r *http.Request) {
var body chatReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+7 -3
View File
@@ -49,6 +49,7 @@ func handlePresetExternal(w http.ResponseWriter, r *http.Request) {
"model": strings.TrimSpace(cfg[extKeyModel]),
"ctx": strings.TrimSpace(cfg["CTX"]),
"hasKey": strings.TrimSpace(cfg[extKeyToken]) != "",
"vision": strings.TrimSpace(cfg[extKeyVision]) == "1",
})
}
@@ -63,6 +64,7 @@ type externalSaveReq struct {
Key string `json:"key"`
Ctx string `json:"ctx"` // taille de contexte (clé CTX), optionnelle
KeyTouched bool `json:"keyTouched"` // l'utilisateur a modifié le champ clé
Vision bool `json:"vision"` // le modèle distant accepte les images
}
func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
@@ -94,13 +96,15 @@ func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
}
}
content := externalPresetContent(req.URL, req.Model, key, req.Ctx)
newID, err := SavePreset(req.ID, req.Name, content)
content := externalPresetContent(req.URL, req.Model, key, req.Ctx, req.Vision)
// Éditer le preset EN SERVICE l'applique tout de suite : sans ça, une URL ou
// une clé corrigée ne servait qu'après une bascule aller-retour.
newID, applied, err := SavePresetApplying(req.ID, req.Name, content)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name})
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name, "applied": applied})
}
// handlePresetExternalTest tente un appel de complétion minimal vers l'endpoint
+108
View File
@@ -0,0 +1,108 @@
package loki
import (
"compress/gzip"
"net/http"
"strings"
"sync"
)
// web_gzip.go — compression gzip des réponses du serveur web LOCAL.
//
// L'interface (≈ 850 Ko de HTML/JS/CSS) et les réponses JSON volumineuses (liste
// des sessions, résultats d'outils, journal d'une conversation relue) partaient en
// clair. gzip les divise typiquement par 4 à 8, ce qui se sent sur un téléphone en
// Wi-Fi. La décision se prend au PREMIER octet, d'après le Content-Type choisi par
// le handler : les flux (text/event-stream), les binaires et ce qui est déjà
// compressé passent tels quels — un flux SSE compressé retiendrait ses événements
// dans le tampon de gzip au lieu de les livrer en direct.
var gzPool = sync.Pool{New: func() any { w, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed); return w }}
func gzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}
gw := &gzipWriter{ResponseWriter: w}
defer gw.finish()
next.ServeHTTP(gw, r)
})
}
type gzipWriter struct {
http.ResponseWriter
decided bool
gz *gzip.Writer
}
// compressible : types textuels uniquement, jamais un flux ni un contenu déjà encodé.
func compressible(h http.Header) bool {
if h.Get("Content-Encoding") != "" {
return false
}
ct := strings.ToLower(h.Get("Content-Type"))
if strings.HasPrefix(ct, "text/event-stream") {
return false
}
return strings.HasPrefix(ct, "text/") || strings.Contains(ct, "json") ||
strings.Contains(ct, "javascript") || strings.Contains(ct, "xml") || strings.Contains(ct, "manifest")
}
func (g *gzipWriter) decide() {
if g.decided {
return
}
g.decided = true
h := g.Header()
h.Add("Vary", "Accept-Encoding")
if !compressible(h) {
return
}
h.Set("Content-Encoding", "gzip")
h.Del("Content-Length")
gz := gzPool.Get().(*gzip.Writer)
gz.Reset(g.ResponseWriter)
g.gz = gz
}
func (g *gzipWriter) WriteHeader(code int) {
// Pas de corps pour ces statuts : ne jamais annoncer un encodage.
if code == http.StatusNoContent || code == http.StatusNotModified || code < 200 {
g.decided = true
}
g.decide()
g.ResponseWriter.WriteHeader(code)
}
func (g *gzipWriter) Write(b []byte) (int, error) {
g.decide()
if g.gz != nil {
return g.gz.Write(b)
}
return g.ResponseWriter.Write(b)
}
// Flush : les handlers qui diffusent (SSE) doivent continuer à pouvoir vider leur
// tampon ; dans ce cas ils ne sont de toute façon pas compressés (voir compressible).
func (g *gzipWriter) Flush() {
if g.gz != nil {
_ = g.gz.Flush()
}
if f, ok := g.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap laisse http.ResponseController atteindre le writer d'origine.
func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }
func (g *gzipWriter) finish() {
if g.gz != nil {
_ = g.gz.Close()
g.gz.Reset(nil)
gzPool.Put(g.gz)
g.gz = nil
}
}
+52
View File
@@ -0,0 +1,52 @@
package loki
import (
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// JSON compressé, flux SSE et binaires laissés tels quels.
func TestGzipHandler(t *testing.T) {
h := gzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/json":
sendJSON(w, 200, map[string]any{"x": strings.Repeat("a", 5000)})
case "/sse":
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: 1\n\n"))
w.(http.Flusher).Flush()
case "/bin":
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte{1, 2, 3})
}
}))
get := func(p string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", p, nil)
r.Header.Set("Accept-Encoding", "gzip, deflate")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
j := get("/json")
if j.Header().Get("Content-Encoding") != "gzip" {
t.Fatal("JSON non compressé")
}
zr, err := gzip.NewReader(j.Body)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(zr)
if !strings.Contains(string(b), strings.Repeat("a", 5000)) || j.Body.Len() > 500 {
t.Fatalf("décompression/ratio inattendus (%d o compressés)", j.Body.Len())
}
if s := get("/sse"); s.Header().Get("Content-Encoding") != "" || s.Body.String() != "data: 1\n\n" || !s.Flushed {
t.Fatal("le flux SSE ne doit pas être compressé et doit rester vidable")
}
if b := get("/bin"); b.Header().Get("Content-Encoding") != "" {
t.Fatal("binaire compressé à tort")
}
}
+10 -8
View File
@@ -89,7 +89,9 @@ func cmdWeb(args []string) error {
// restent ouverts aussi longtemps que l'utilisateur regarde la page — et
// depuis mountOAI, il couperait aussi les complétions en streaming des
// clients tiers, en plein milieu d'une génération.
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
// gzipHandler : l'UI (~590 Ko) et les gros JSON partent compressés ; les flux
// SSE et les binaires passent tels quels (voir web_gzip.go).
srv := &http.Server{Handler: gzipHandler(mux), ReadHeaderTimeout: 10 * time.Second}
return srv.Serve(ln)
}
@@ -330,6 +332,7 @@ func newWebMux() *http.ServeMux {
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
api("/api/chat/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
// Discussions : liste, création, bascule, renommage, suppression.
api("/api/conversations", handleConvList)
@@ -337,13 +340,12 @@ func newWebMux() *http.ServeMux {
api("/api/conversations/switch", handleConvSwitch)
api("/api/conversations/rename", handleConvRename)
api("/api/conversations/delete", handleConvDelete)
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
api("/api/chat/tool-result", handleToolResult) // résultat complet d'un outil, chargé à la demande (bouton « voir plus »)
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
return mux
}
+35
View File
@@ -0,0 +1,35 @@
package loki
import (
"net/http/httptest"
"strings"
"testing"
)
// La bascule vise le preset par son id, pas par sa position : une liste
// réordonnée sur un autre appareil faisait charger un AUTRE preset. Presets
// externes ici : la bascule n'a alors aucun moteur local à redémarrer.
func TestBasculeParIdentifiant(t *testing.T) {
testHome(t)
if _, err := SavePreset("", "Alpha", externalPresetContent("https://a.example/v1", "modele-a", "", "", false)); err != nil {
t.Fatal(err)
}
idB, err := SavePreset("", "Beta", externalPresetContent("https://b.example/v1", "modele-b", "", "", false))
if err != nil {
t.Fatal(err)
}
post := func(body string) int {
w := httptest.NewRecorder()
handleSwitch(w, httptest.NewRequest("POST", "/api/switch", strings.NewReader(body)))
return w.Code
}
if code := post(`{"id":"` + idB + `","n":1}`); code != 200 {
t.Fatalf("bascule par id : code %d", code)
}
if got := ReadConfig()[extKeyModel]; got != "modele-b" {
t.Fatalf("preset chargé = %q, attendu celui de l'id (modele-b) et non la position 1", got)
}
if code := post(`{"id":"inexistant"}`); code != 404 {
t.Fatalf("id inconnu : code %d, attendu 404", code)
}
}
+3
View File
@@ -177,6 +177,9 @@ func imageMime(name string) string {
// seule où envoyer une image AU MODÈLE a un sens. Sans projecteur, llama-server
// rejetterait un contenu image ; on s'en tient alors au dépôt-fichier.
func visionEnabled() bool {
if externalActive() {
return externalVisionActive() // modèle distant déclaré multimodal
}
return strings.TrimSpace(ReadConfig()["MMPROJ"]) != ""
}