Compare commits

..
Author SHA1 Message Date
Claude 895aa67366 Presets : le bouton « + » ne s'affichait jamais
.preset-add est en display:none par défaut, et n'était révélé que par
« details[open]>summary>.preset-add ». Les panneaux de réglages ont
migré vers <section class="set-pane"><div class="set-pane-h">, donc ce
sélecteur ne matchait plus : le « + » des Presets restait invisible et
il n'y avait plus aucun moyen d'en créer un depuis l'interface.

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

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

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

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

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

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

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

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

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

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

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

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

Pour la durée, deux causes distinctes :

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:35:42 +02:00
LogiFlow f4969de9f7 Merge pull request #19 from R0m1k3/feat/dictee-temps-reel
Dictée : whisper-server supervisé, modèle et GPU au choix
2026-08-21 21:22:18 +02:00
MichaelandClaude Opus 5 5d391c4f73 Dictée : panneau de réglages — GPU, modèle, langue, test du micro
Le choix du matériel et du modèle n'existait nulle part : la dictée prenait
le seul modèle codé en dur et le seul chemin possible. Sur une machine à
plusieurs GPU, rien ne permettait de dire lequel prêter à whisper.

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

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

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

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

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

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

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

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

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

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

Deux points tranchés en cours de route :

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Version 0.12.3.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:16:28 +02:00
MichaelandClaude Opus 5 4e5bc44607 Captures d'écran : montrées dans la bulle, sans dépendre du modèle
La capture n'apparaissait dans le fil QUE si le modèle recopiait la ligne
markdown rendue par l'outil. Un petit modèle l'oublie, et l'utilisateur
ne voyait jamais l'image qu'il avait demandée — la capture existait
pourtant bien sur le disque.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:31:37 +02:00
Michael ebe04f9d61 Merge branch 'feat/mcp-catalog-go' 2026-08-17 10:06:19 +02:00
MichaelandClaude Opus 5 847aad217b MCP : catalogue embarqué de serveurs connus
Ajouter un serveur MCP demandait d'écrire soi-même `npx -y @scope/paquet …`
dans la modale, en devinant le nom du paquet. Le catalogue liste une vingtaine
de serveurs connus, commande déjà renseignée, classés par catégorie.

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

Intensité du raisonnement (REASONING_EFFORT)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ce qui change concrètement :

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 15:33:55 +00:00
LogiFlow d42c334b9a Merge pull request #5 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Claude/ajean loki container fork aep9r2
2026-08-16 14:37:02 +02:00
Claude 00b9cc2aee Panneau Fichiers : accéder à ce que l'agent écrit
L'agent produit des rapports, des scripts, des exports et des captures dans son
dossier de travail. On ne pouvait en récupérer un que si le modèle avait pensé à
en mettre le lien dans sa réponse : tout ce qu'il écrivait sans le dire restait
invisible, et faire le ménage demandait un shell.

Un panneau de droite, ouvert et fermé par le bouton dossier du pied de carte,
liste ce dossier avec navigation dans les sous-dossiers, téléchargement et
suppression. Il pousse la conversation au lieu de la recouvrir — la largeur de
lecture étant pilotée par --measure, le fil se resserre et se recentre tout
seul. Sur téléphone c'est un tiroir, comme les réglages à gauche.

Le socle existait : /api/chat/file téléchargeait déjà, workspaceRel bornait déjà
les chemins, downloadWorkspaceFile gérait déjà le blob. Manquaient les deux
verbes qui comptent, /api/chat/files pour lister et /api/chat/file/delete pour
supprimer.

Trois choix qui méritent d'être dits :

- Un dossier affiche la taille de TOUT son contenu, pas celle de son inode :
  c'est ce qu'on libère en le supprimant, donc c'est le chiffre qui aide à
  décider. Le parcours est borné à 20 000 entrées pour que la liste reste
  instantanée si l'agent y dézippe quelque chose d'énorme.
- La racine du dossier de travail est refusée à la suppression : l'agent y perd
  le dossier dans lequel il écrit, et « tout effacer » ne doit pas être à un
  clic. La suppression d'un dossier annonce le nombre de fichiers et le poids
  emportés avant de demander confirmation.
- Le panneau ne se remplit qu'à l'ouverture. Un ReadDir récursif à chaque
  chargement de page pour un panneau fermé serait payé par tout le monde.

Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` : la
gouttière de barre de défilement mesurée dans --sbw resterait celle de l'ancienne
largeur et la carte de saisie serait décalée du fil. On resynchronise donc
explicitement ; la hauteur, elle, est déjà suivie par un ResizeObserver.

Vérifié. Six tests Go sur le bornage, qui est la partie sensible — ces routes
suppriment sur disque à partir d'un chemin venu du navigateur : « .. », chemins
absolus, racine, et un lien symbolique posé dans le dossier de travail sont tous
refusés, et le fichier voisin survit. Puis au navigateur, en clair et en sombre :
listing trié dossiers d'abord, taille récursive, descente et fil d'Ariane,
téléchargement réel (l'événement download porte bien rapport.md), suppression
confirmée puis disparition de la ligne, persistance de l'état ouvert, tiroir
mobile avec voile, et la saisie reste alignée au pixel sur le fil (0 px des deux
côtés) une fois le panneau ouvert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
2026-08-16 12:25:57 +00:00
Claude 133b30421a Postes distants : retirer le bouton oublié du composeur
Le retrait de l'accès distant s'était arrêté à mi-chemin. La section « Accès
distant » de la barre latérale et son module 13-remote.js ont bien disparu au
commit 3740b88, mais l'autre moitié du même sujet est restée : le bouton
« Postes distants » du composeur, l'icône écran/wifi sous « send ».

Ce sont deux fonctionnalités distinctes dans le code (relay_* pour le tunnel
ajean.link, node_* pour les postes appairés), mais à l'usage c'est la même
chose : faire agir Loki ailleurs que sur le serveur qui l'héberge. Sur une
installation en conteneur, ni l'une ni l'autre n'a d'objet, et ce bouton
occupait une place dans une ligne d'outils déjà dense pour ouvrir une modale
d'appairage qui ne servira jamais.

Partent avec lui : les deux modales (celle d'appairage servait aussi d'écran
d'édition d'un poste, via un remplacement de pied), le module 15-node.js
entier, et les règles CSS #node-btn et .node-*.

Le code serveur ne bouge pas — mêmes raisons qu'en 3740b88 : les routes
/api/node/* et le serveur WebSocket d'appairage restent en place mais
deviennent inatteignables, plus rien ne pouvant générer de code d'appairage.
Les retirer créerait un conflit à chaque reprise de l'amont pour un gain nul.

Un piège mérite d'être signalé, et il est maintenant commenté dans le code :
loadAll() enchaîne ses chargements dans un Promise.allSettled, qui AVALE une
ReferenceError. Oublier de retirer loadNode() de cette ligne n'aurait rien
cassé visiblement — la page se serait simplement chargée à moitié. Le test
navigateur écoute donc pageerror et échoue au moindre message.

Vérifié en 1400×900, thèmes clair et sombre : aucune erreur JS au chargement,
bouton et modales absents du DOM, openNodeHub indéfini, et le pied de carte
garde son décompte de contexte, « compacter » et le trombone, tous atteignables
au clic. Le composeur perd 26 px de large ; comme sa hauteur est mesurée depuis
le correctif précédent, la réserve sous le fil suit toute seule — overlap.js
repasse au vert de 380 à 2000 px.

Deux échecs relevés au premier passage venaient du banc d'essai, pas du code :
/api/backends/devices répond 400 parce que le BIN du fixture n'est pas un vrai
llama-server, et « compacter » est masqué tant que le contexte est sous 50 %.
Les assertions ont été corrigées, pas le code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
2026-08-16 11:32:12 +00:00
Claude e7863d732c README : remettre la documentation en accord avec l'application
Le README décrivait encore l'état d'AJEAN sur plusieurs points devenus faux, et
passait sous silence ce que ce fork a ajouté.

Corrections de fond :

- L'accès distant chiffré ajean.link était annoncé en tête et dans les
  fonctionnalités. Sa section d'interface et son module JS ont été retirés ; le
  code serveur reste en place mais inerte, et c'est ce que le README dit
  maintenant plutôt que de promettre une fonctionnalité absente.
- Le catalogue de modèles distant est documenté comme retiré, avec la raison.
- Le titre de discussion vient du premier message, pas d'un modèle : dire
  « généré automatiquement » aurait laissé croire à un appel d'inférence.
- L'outil de capture d'écran est toujours proposé au modèle ; c'est sa
  DESCRIPTION qui suit la vision réelle du moteur. La première rédaction disait
  que l'outil était retiré — c'est faux.
- Les captures vivent sous workspace/captures/<id>/, pas directement sous /data.

Ajouts :

- Section « Installer un modèle » : recherche Hugging Face, verdict mémoire et
  son caractère estimatif assumé, projecteur vision du même dépôt, repères sur
  les quants Dynamic d'unsloth et sur ggml-org, mode expert par lien direct.
- Section « Données et persistance » : ce que contient chaque chemin sous
  /data, la commande docker inspect pour vérifier le montage, et les deux
  pièges rencontrés sur Unraid — /mnt/user contre /mnt/cache, et les conteneurs
  relancés avant que le pilote Nvidia soit chargé (ERROR init result=11).
- Discussions multiples, identité (prénom + avatars), regroupement des
  Paramètres, HF_TOKEN dans le tableau des variables.
- Le tableau des différences avec l'amont gagne quatre lignes : panneau Moteur,
  choix du modèle, historique de tchat, accès distant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
2026-08-16 11:09:39 +00:00
LogiFlow 59ebbf6f8d Merge pull request #4 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Chercher et installer un modèle depuis Loki
2026-08-16 13:04:13 +02:00
Claude 26e93f43ef Chercher et installer un modèle depuis Loki
Installer un modèle demandait d'aller sur huggingface.co, de naviguer dans
l'arborescence d'un dépôt et de coller un lien à la main. Rien ne disait si le
fichier tiendrait en mémoire, et surtout rien ne reliait un modèle à SON
projecteur vision : c'est ainsi qu'un mmproj-Qwen3VL-8B s'est retrouvé
configuré pour un Qwen3.8-27B — deux modèles sans rapport, moteur qui démarre
et ne voit rien.

La recherche Hugging Face arrive dans l'éditeur de preset. Elle ne remonte que
les dépôts GGUF ; déplier un dépôt montre ses quantifications avec leur taille
et un verdict mémoire, et propose le projecteur vision DU MÊME DÉPÔT — le seul
qui corresponde. Quand le dépôt n'en publie pas, Loki le dit au lieu d'aller en
chercher un ailleurs.

Le nouveau code ne télécharge rien : il produit des URL que le chemin existant
consomme tel quel (normalizeHFURL, shardURLSet, sonde d'espace disque, reprise
et annulation). Une file d'attente enchaîne modèle puis projecteur — le serveur
ne mène qu'un transfert à la fois, et le champ Vision ne se remplit que si le
modèle est déjà sélectionné.

Trois familles de .gguf cohabitent dans un dépôt et ne veulent pas dire la même
chose : mmproj-* (projecteur), mtp-* (poids de décodage spéculatif) et le
modèle. Les deux premières ressemblent à un modèle ; les proposer en vrac, ce
serait offrir de lancer llama-server sur un encodeur d'images. Les tranches
d'une famille sont repliées en une entrée de taille TOTALE : annoncer 15 Go
pour un modèle qui en occupe 45 promet une place qui n'existe pas.

Le verdict mémoire s'appuie enfin sur le GPU. detectHardware() ne renvoyait que
la RAM système alors que detectGPUs() existait déjà : sur un serveur à carte
NVIDIA, le verdict se prononçait sur la mauvaise grandeur. Le coût du cache KV
reste une estimation assumée — l'exact demanderait de parser l'en-tête GGUF —
et l'interface l'annonce comme telle plutôt que d'afficher un chiffre
faussement sûr.

Le catalogue ajean.link disparaît. Sa route n'avait aucun consommateur (l'écran
d'accueil qu'elle attendait n'a jamais existé), son repli embarqué proposait du
Qwen2.5 de 2024, et un fork qui laisse le serveur de l'amont décider de ce
qu'il propose n'est pas vraiment un fork.

Une piste écartée en cours de route : marquer les dépôts « vision » d'après les
tags Hugging Face. Ils mentent — des deux dépôts GGUF de Qwen3.8-27B qui
publient tous deux un mmproj, seul ggml-org est taggé image-text-to-text. Une
pastille sur l'un et pas sur l'autre aurait été pire que rien. La vision est
donc déduite de la seule source qui ne se trompe pas : la présence d'un
mmproj-*.gguf dans l'arborescence.

Vérifié : 8 tests unitaires sur les arborescences réelles des deux dépôts, puis
au navigateur contre le vrai Hugging Face — 25 dépôts trouvés, 3 quants listés
sans aucun mtp ni mmproj, projecteur Q8_0 proposé et coché, verdicts affichés
avec leur explication, modale dans l'écran. L'ordre de la file (modèle puis
projecteur) est testé en interceptant les appels, sans transfert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
2026-08-16 11:02:35 +00:00
LogiFlow 948aa5c54b Merge pull request #3 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Le fil ne passe plus sous la carte de saisie
2026-08-16 11:14:28 +02:00
Claude 1fa8452e44 Le fil ne passe plus sous la carte de saisie
Sur écran large, la fin de la conversation était recouverte par la carte de
saisie et aucun défilement ne la ramenait : le fil réservait 8 px sous lui
là où le composeur en occupe ~160.

styles.css réservait bien la hauteur (#chat{padding-bottom:170px}), mais deux
règles POSTÉRIEURES dans la feuille — ajoutées par le redesign « Terre » puis
par le correctif de largeur de lecture — réécrivaient `padding` en raccourci
sous @media (min-width:721px), ce qui remet padding-bottom à la valeur du
raccourci. Sous 721 px la règle en raccourci est déclarée AVANT, d'où un bug
invisible sur mobile. Ces règles passent en longhands (padding-top +
padding-inline), et un commentaire dit pourquoi le raccourci est proscrit ici.

La réserve devient mesurée au lieu d'être devinée : syncComposer() publie
--composer-h (hauteur réelle du composeur) via un ResizeObserver, à côté de
syncGutter() qui fait déjà ce travail pour --sbw. Une constante redevenait
fausse dès que la saisie grandit ou qu'une pièce jointe s'affiche. Le
scrollbtn suit la même variable. env(safe-area-inset-bottom) disparaît du
calcul : il est déjà dans la hauteur mesurée, le compter deux fois creusait
un trou en PWA iOS.

Le voile de fondu au-dessus de la carte s'exprimait en % de la hauteur TOTALE
du composeur : l'opacité pleine tombait sous le bord haut de la carte, et le
texte restait lisible au ras du bord — l'impression de « passer derrière ».
Une variable --fade pilote désormais la bande réservée ET la course du
dégradé, qui devient donc opaque exactement au bord de la carte.

Mesuré au navigateur, du 380 px au 2000 px, au repos / saisie 10 lignes /
pièce jointe en attente : écart de 46 à 52 px partout. En réinjectant
l'ancienne règle : -119 px, bug reproduit. La largeur de lecture est
inchangée (1080 px de contenu à 2000 px de fenêtre).

Barre latérale : la discussion sélectionnée porte un cadre sauge complet et
fin (1 px) au lieu d'un liseré de 3 px sur le seul bord gauche. La bordure
transparente est portée par tous les états, la sélection ne décale donc pas
le contenu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
2026-08-16 09:09:38 +00:00
Loki 3740b881a8 Hauteur : bulles techniques en rangée, sections groupées, accès distant retiré
Trois gains de place, mesurés au navigateur en 1500×900.

- Raisonnement, outils et mémoire se posent CÔTE À CÔTE dans une rangée qui
  déborde à la ligne : un tour d'agent en enchaîne parfois dix, et empilées
  elles repoussaient la réponse hors de l'écran alors qu'elles ne portent
  qu'une étiquette tant qu'elles sont repliées. Mesuré : 8 bulles occupent
  2 lignes au lieu de 8. Dépliée, une bulle reprend toute la largeur — son
  contenu ne doit pas être comprimé dans une colonne étroite.

- Les sections de la barre latérale sont réunies en DEUX cartes (réglages
  d'IA / réglages d'application) au lieu d'une carte chacune : onze arrondis
  et autant de gouttières faisaient déborder la colonne. Mesuré : 876 px de
  contenu pour 876 px visibles — plus d'ascenseur.

- Section « Accès distant » supprimée, ainsi que son module JS (13-remote.js)
  et son chargement : elle pilotait le tunnel ajean.link, sans objet ici.
  Le code serveur du relais reste en place mais inerte (aucun jeton) : le
  retirer créerait des conflits à chaque reprise de l'amont.
2026-08-15 22:20:46 +00:00
Loki c90a2d86eb Largeur de lecture : suivre le panneau au lieu d'une colonne étroite centrée
Sur un écran large, le fil se réduisait à ~590 px de texte entouré de vide :
la mesure était plafonnée à 760 px et centrée sur la fenêtre, alors que la
charte fait courir le texte sur toute la largeur du panneau.

Elle était en plus codée à TROIS endroits avec des valeurs différentes
(450 / 380 / 360 px), donc condamnée à diverger entre le fil et la carte de
saisie. Une seule variable --measure les pilote désormais tous les deux.

La borne haute reste : au-delà, l'œil perd le début de la ligne suivante.
1080 px ≈ 110 caractères — large, mais encore lisible.

Mesuré au navigateur en 2000 px : réponse 1050 px (contre 589), question
788 px (75 %, conforme à la charte), carte de saisie alignée sur le fil.
2026-08-15 22:03:28 +00:00
Loki e7a3bcca1f Direction « Terre » : nouvelle identité visuelle
Reprise de la maquette fournie (LOKI — direction Terre) : bruns chauds, vert
sauge, argile, et des surfaces en CARTES arrondies séparées par une gouttière
au lieu d'une colonne à filets.

- Palette transposée sur les jetons EXISTANTS (--bg, --panel, --accent…) et
  non par réécriture des 1200 lignes : les 300 usages de variables suivent
  d'eux-mêmes. Rôles clarifiés — sauge = sélection et action, argile =
  chiffres et avertissements, terre cuite = arrêt et danger.
- Polices Bricolage Grotesque + IBM Plex Mono EMBARQUÉES dans le binaire et
  servies par Loki (/fonts/), pas par Google Fonts : une instance locale ou
  coupée d'internet doit s'afficher correctement, sans qu'un tiers apprenne
  qui consulte l'interface. Sous-ensemble latin, 106 Ko au total.
- Structure en cartes : barre latérale en pile de cartes, colonne de chat en
  panneau unique, carte de saisie posée un ton au-dessus. Liseré sauge sur la
  discussion active, bulle de question au coin bas-droit rentré.
- Bouton d'envoi en sauge : il reprenait --text, donc un brun indistinct du
  reste ; l'arrêt reste en terre cuite.

Bornage au bureau (min-width:721px) : sur téléphone la barre latérale est un
tiroir plein écran et le fil occupe toute la largeur — des cartes n'y
grignoteraient que des pixels utiles.

Vérifié au navigateur, thèmes clair ET sombre, plus un rendu 390 px :
polices réellement chargées (200 sur les trois woff2), aucun débordement
horizontal, tiroir mobile inchangé.
2026-08-15 21:50:30 +00:00
Loki 46c5a1f130 Pièces jointes : mêmes garde-fous vision que les captures d'écran
Vu en production : un projecteur mmproj d'un AUTRE modèle sélectionné dans le
preset (Qwen3-VL-8B sur un Qwen3.8-27B). Le projecteur ne se charge pas,
mais visionEnabled() — qui ne teste que la clé MMPROJ — répondait vrai : la
pièce jointe partait en image_url que le gabarit sérialisait en texte. Un
PNG de 3,5 Mo devenait des dizaines de milliers de tokens dans le contexte,
et le modèle annonçait quand même « je ne peux pas voir les images ».

userMessageContent exige désormais aussi engineSeesImages() (/props,
modalities.vision) — la même double condition que le relais des captures.
Sans vision effective, la pièce jointe est annoncée comme fichier, comme
avant le multimodal.
2026-08-15 14:58:27 +00:00
Loki 4854ae3784 Passe de qualité : contexte explosé par les captures, sonde vision, courses
Le journal moteur de production a livré la vraie cause première des 500 en
cascade : « request (55407 tokens) exceeds the available context size
(32768) ». Le message multimodal qui relaie une capture d'écran au modèle
était PERSISTÉ dans l'historique ; son base64 (des dizaines de milliers de
tokens) repartait à chaque tour, et la conversation dépassait
définitivement la fenêtre — plus aucun tour ne passait, et l'exception
Jinja du rattrapage (corrigée au commit précédent) masquait tout.

- L'image devient ÉPHÉMÈRE : jointe au tour en cours, jamais à l'historique.
  Le modèle la regarde maintenant ; sa description textuelle, elle, reste.
- stripImageParts guérit les conversations déjà empoisonnées au chargement
  et à la bascule : parties image retirées, texte aplati.
- engineSeesImages : l'image n'est envoyée que si llama-server DÉCLARE la
  vision (/props, modalities.vision, cache 10 s). La clé MMPROJ ne suffit
  pas — projecteur d'un autre modèle ou modèle sans vision (gpt-oss), le
  gabarit sérialise le base64 en texte. La description de l'outil suit le
  même état : ne jamais promettre une image qui n'arrivera pas.

Deux défauts trouvés à l'analyse, au passage :
- renderBody reconstruit le DOM à chaque delta du streaming : une image déjà
  affichée était RE-TÉLÉCHARGÉE à chaque token arrivé après elle. Cache de
  blobs par URL, une seule requête par capture.
- les opérations de discussions (création, bascule, renommage, suppression)
  entrelaçaient leurs lectures-écritures d'index sous requêtes simultanées :
  sérialisées par un verrou dédié.

Tests : historique guéri (aplati sans l'image), transmission conditionnée à
la sonde, description alignée ; suite complète, vet, staticcheck verts.
2026-08-15 14:43:41 +00:00
Loki 73196a1ff6 Erreur 500 : la consigne de repli ne doit pas casser le gabarit du modèle
Après un 500 de llama-server, l'amont relance le tour sans outils en ajoutant
une consigne système À LA FIN de la conversation. Or plusieurs gabarits
exigent que le message système soit le PREMIER : gpt-oss (rôle « developer »)
lève « System message must be at the beginning ». La tentative de
récupération produisait donc elle-même un 500, et cette exception Jinja
remplaçait l'erreur d'origine sous les yeux de l'utilisateur.

steerSystem fusionne désormais la consigne dans le message système de tête,
ou la pose en première position s'il n'y en a pas — jamais ailleurs. La liste
de l'appelant n'est pas modifiée.

Tests : fusion sans ajout de message, création en tête quand il n'y a pas de
système, et non-modification de la liste d'origine.
2026-08-15 14:36:04 +00:00
Loki 8b76d8de9c Prénom : écarter l'autocomplétion des gestionnaires de mots de passe
Le champ « Ton prénom » n'annonçait rien sur sa nature : les gestionnaires le
prenaient pour un identifiant de connexion et recouvraient la saisie d'une
liste de comptes enregistrés (192.168.1.16 / admin…), rendant le champ
pénible à remplir.

autocomplete="off" ne suffit pas seul — la plupart des gestionnaires
l'ignorent délibérément sur ce qu'ils croient être un formulaire de
connexion. On ajoute donc les marqueurs qu'ils respectent réellement :
data-1p-ignore (1Password), data-lpignore (LastPass), data-form-type="other"
(Dashlane), et un name explicite qui n'évoque ni user ni login.
2026-08-15 14:28:11 +00:00
Loki 72a8925cb4 Avatars et prénoms : les afficher pour de bon (ils étaient masqués par le CSS)
Les libellés étaient bien construits — mon test précédent vérifiait le HTML
produit, pas le style appliqué — mais la feuille de style les masquait tous :

  #chat .msg .label{display:none}

C'était un choix justifié de l'amont, où le libellé ne disait que « user » ou
« assistant », deux mots que l'alignement des bulles rend redondants. Depuis
qu'ils portent un avatar et un prénom, ils identifient réellement
l'interlocuteur : on les réaffiche pour user et assistant, en ligne et
discrets, aligné à droite côté question. Les bulles techniques non repliables
restent muettes.

Vérifié dans un navigateur : display=flex, boîte non vide, textes rendus
« 🙂 Michael » et « 🦊 Loki » — cette fois sur le style calculé et sur une
capture d'écran, pas seulement sur le DOM.
2026-08-15 13:10:02 +00:00
Claude 07ce51f2ab Merge branch 'claude/ajean-loki-container-fork-aep9r2' 2026-08-15 12:53:56 +00:00
Loki 3ecbfe274e Captures : polices installées, et image réellement montrée au modèle
Deux défauts signalés à l'usage, tous deux réels.

1) Captures sans aucun texte. Le conteneur n'avait pas de polices : Chromium
   rendait images et aplats, mais pas un caractère. Ajout de Noto (écritures
   du monde, CJK, emoji), Liberation et DejaVu (substituts d'Arial/Times que
   réclament la plupart des sites), plus fc-cache. Le délai avant capture
   passe à 3,5 s : de nombreux sites chargent leurs polices en webfont et
   laissent le texte invisible le temps du téléchargement (font-display:
   block), ce qui produisait aussi des blocs vides.

2) « Je n'ai pas de vision » alors que le projecteur était configuré. Le
   modèle disait vrai deux fois : la description de l'outil lui affirmait
   « Tu ne vois pas l'image », et la capture ne lui était jamais transmise —
   un message  ne transporte que du texte.
   - la description SUIT désormais l'état du projecteur, comme web_open suit
     le moteur web choisi (même motif que l'amont) ;
   - la capture est relayée dans un message  multimodal (text +
     image_url en data URI), le format déjà utilisé par les pièces jointes.
     Sans projecteur, rien n'est envoyé : llama-server rejetterait l'image.

Vérifié : 359 polices dans l'image et texte lisible sur une capture réelle ;
tests sur la description et le relais selon MMPROJ, et sur l'extraction du
chemin de capture (si le texte de l'outil change, le relais casserait en
silence).
2026-08-15 12:53:54 +00:00
Claude 556defaba1 Merge branch 'claude/ajean-loki-container-fork-aep9r2' 2026-08-15 12:07:42 +00:00
Loki 801e65dac2 Captures : légères, plafonnées, et supprimées avec leur discussion
Trois causes d'encombrement traitées :

- JPEG au lieu de PNG (Playwright déduit le format de l'extension). Sur une
  vraie page web — photos, dégradés — le JPEG pèse 3 à 10 fois moins ; le PNG
  ne gagnait que sur les aplats, cas minoritaire ici.
- Pleine page DÉSACTIVÉE par défaut : un article long capturé en entier fait
  plusieurs milliers de pixels de haut, donc plusieurs Mo, alors que « montre
  cette page » veut presque toujours dire le premier écran. Le modèle peut
  toujours demander full_page.
- Ménage automatique : au plus 20 captures et 40 Mo par discussion, les plus
  anciennes partant en premier. La capture qui vient d'être prise n'est
  JAMAIS supprimée — une capture plus lourde que le plafond se serait effacée
  elle-même, et le modèle aurait renvoyé un lien mort.

Les captures sont désormais rangées PAR DISCUSSION (captures/<id>/…) :
supprimer une discussion, ou la vider, emporte ses images. Sans ce rangement,
des fichiers que plus aucun message n'affiche restaient sur le disque.

Tests : ménage (nombre et octets), survie de la capture courante, et
suppression qui n'emporte que les captures de la discussion visée.
2026-08-15 12:07:41 +00:00
Claude 627a12306b Merge branch 'claude/ajean-loki-container-fork-aep9r2' 2026-08-15 11:54:53 +00:00
Loki 6fe94b9a8d Captures de pages web (Playwright) affichées dans le fil
Outil web_screenshot : Chromium piloté par Playwright photographie une page
RENDUE (JavaScript exécuté) dans le dossier de travail, et rend au modèle la
ligne markdown exacte à recopier — lui laisser composer l'URL reviendrait à
lui faire inventer un chemin, donc une image cassée.

INDÉPENDANT DE LA VISION, souvent confondu : visionEnabled() (clé MMPROJ) ne
décide que d'une chose, l'envoi d'images AU MODÈLE. Capturer et afficher ne
passent pas par le modèle — sans projecteur, l'agent photographie sans
regarder, ce qui suffit à illustrer une conversation.

- route /api/chat/image : sert UNIQUEMENT des images du dossier de travail,
  en ligne. handleChatFile force le téléchargement de tout pour qu'un .html
  du modèle ne s'exécute pas dans l'origine de l'UI ; ici la même règle est
  tenue autrement — type déduit du CONTENU (pas de l'extension), nosniff, et
  CSP default-src 'none'. Un faux .png contenant du HTML est refusé en 415.
- UI : une balise <img> ne peut pas porter d'en-tête Authorization, or /api/*
  exige la clé dès qu'elle est définie. Les images sont donc récupérées par
  fetch authentifié puis posées en blob:.
- l'outil n'est déclaré au modèle QUE si Playwright est réellement présent :
  annoncer un outil absent envoie le modèle en boucle de réessai.
- Node 22 (NodeSource) au lieu du Node 18 d'Ubuntu, exigé par Playwright et
  par la plupart des serveurs MCP. PLAYWRIGHT=0 bâtit une image sans navigateur.
- CONFIG ACTIVE affichait « llama.cpp personnalisé » pour le moteur de
  l'image : il annonce maintenant « llama.cpp de l'image ».

Description de l'outil tenue au plus court : TestSystemPromptStaysLean a
attrapé le dépassement du budget de préambule (7888 car pour 7500).

Vérifié dans l'image : capture réelle d'une page, servie en image/png avec
CSP ; faux PNG rejeté (415) ; évasion du workspace bloquée (403).
Playwright ajoute 816 Mo à l'image.
2026-08-15 11:54:52 +00:00
Claude ea80c696ad Merge branch 'claude/ajean-loki-container-fork-aep9r2' 2026-08-15 11:36:43 +00:00
Loki 54c5eb1334 Identité dans le chat, section Paramètres, logo agrandi
- Identité : prénom et avatars (emoji) affichés en tête des bulles, à la
  place des libellés « user » / « assistant ». Stockés dans les préférences
  SERVEUR, donc partagés entre appareils comme le thème. La liste d'emojis
  est servie par /api/prefs : pas de seconde copie côté client.
  Validation stricte — prénom nettoyé de ses caractères de contrôle et borné
  à 24 runes, avatar refusé s'il n'est pas dans la liste fermée.
- Barre latérale : séparateur « Paramètres » qui regroupe Identité,
  Apparence, Accès OpenAI, Accès distant et Actions, sous les réglages d'IA
  (Machine, Presets, Mode agent, System prompt, Moteur). Un séparateur plutôt
  qu'un second niveau de repli, illisible dans une colonne aussi étroite.
- Logo : 10 → 17 px, il était minuscule sur un écran dense.

Le prénom vient de l'utilisateur : les libellés sont écrits en textContent,
jamais en innerHTML.
2026-08-15 11:35:40 +00:00
Claude 7472de0a41 Merge branch 'claude/ajean-loki-container-fork-aep9r2' 2026-08-15 11:11:41 +00:00
Loki ecaa847439 NGL=auto : laisser llama.cpp ajuster les couches à la VRAM libre
Loki passait TOUJOURS -ngl, avec 999 comme repli quand le champ est vide :
vider « Couches sur GPU » ne changeait donc rien. Or llama.cpp sait ajuster
lui-même le nombre de couches à la mémoire libre (common_fit_params), mais y
renonce dès que la valeur est imposée — « n_gpu_layers already set by user to
999, abort ». D'où, sur une carte trop juste pour le contexte demandé, des
cudaMalloc en échec, un repli partiel sur le CPU, et un débit effondré alors
que le GPU tourne à 100 %.

NGL=auto n'envoie plus -ngl du tout. Le défaut reste 999 : omettre le drapeau
sur un llama.cpp antérieur à cet ajustement le ferait tourner 100 % CPU.
Le champ de l'UI passe en texte pour accepter « auto ».

Vérifié : NGL=auto → aucun -ngl dans la ligne de commande ; NGL=999 → -ngl 999.
2026-08-15 11:11:40 +00:00
Claude 514351a1db Merge branch 'claude/ajean-loki-container-fork-aep9r2' 2026-08-15 11:07:47 +00:00
Loki 2fbf86cdde Historique : plusieurs discussions, et mise à jour par l'image en conteneur
L'amont ne connaît qu'un fil unique (bkChat/conversation) que « clear chat »
effaçait définitivement. On garde toute la machinerie (un seul conv en
mémoire, mêmes flux SSE, même compactage) mais rangée par discussion :

  bkChat/index      liste des discussions (métadonnées seules)
  bkChat/active     discussion ouverte, partagée par tous les appareils
  bkChat/conv:<id>  état complet d'une discussion

Basculer réutilise le mécanisme d'epoch du reset : les abonnés SSE reçoivent
{reset:true} et rejouent le nouveau fil — aucun code de rendu à toucher. Le
fil unique existant est repris comme première discussion au premier
démarrage, et sa clé d'origine est laissée intacte.

- routes /api/conversations (liste, new, switch, rename, delete)
- barre latérale : liste (titre déduit du 1er message, date, nb d'échanges),
  bouton +, renommer, supprimer ; lignes construites en DOM et non en
  innerHTML, les titres venant de messages utilisateur
- suppression de la dernière discussion : convCreate et non convNew, qui
  aurait réenregistré celle qu'on vient d'effacer

Le bouton « Vérifier les mises à jour » répondait « GitHub a répondu 404 » :
il interrogeait les releases du dépôt du fork, qui n'en publie aucune. En
conteneur, remplacer le binaire n'a de toute façon pas de sens — l'UI, l'API
et la CLI renvoient désormais « docker compose pull ».

Vérifié en conteneur : création, bascule, renommage (titre accentué avec
< > &), suppression de l'active puis de la dernière ; /api/update et
loki update renvoient la note Docker.
2026-08-15 11:07:45 +00:00
Claude cf5da7fa38 Merge branch 'claude/ajean-loki-container-fork-aep9r2' 2026-08-15 08:37:50 +00:00
Loki 1f24d8ccf5 Sélecteur de moteur par modèle : « Image » en conteneur, et mention du fork
Le sélecteur de l'éditeur de modèle proposait encore « Précompilé » et
« Compilé » — deux installations que Loki ne fait pas en conteneur : cliquer
répondait « installez d'abord llama.cpp… ». Pire, le moteur de l'image ne
correspondant à aucun des deux, il était classé « Personnalisé ».
En conteneur, ces deux options laissent place à « Image » (sélectionnée par
défaut, y compris pour un preset sans BIN) ; « Personnalisé » reste pour un
binaire déposé dans /data/backends.

Le moteur de l'image est désormais déclaré par l'image elle-même via
LOKI_ENGINE_BIN (Dockerfile + entrypoint) au lieu d'être deviné d'après le
chemin : /api/llamacpp expose provided et provided_bin.

Attribution : sous le nom Loki, « fork de AJEAN » avec lien vers le dépôt
d'origine.

Vérifié dans l'image : provided=true, provided_bin=/app/llama-server,
BIN semé identique ; build, vet et tests verts.
2026-08-15 08:37:48 +00:00
Claude 53ba98f3eb Merge branch 'claude/ajean-loki-container-fork-aep9r2' 2026-08-15 07:49:53 +00:00
Loki 5b434a4a59 UI : moteur fourni par l'image, et journal du moteur repliable
Le panneau « Moteur » proposait d'installer llama.cpp alors que l'image le
fournit déjà (/app/llama-server) : cliquer lançait une compilation qui
échouait sur « outils manquants : cmake », l'image n'embarquant ni cmake ni
compilateur.
- engineProvided() : vrai en conteneur quand BIN désigne un exécutable
  existant HORS de LOKI_HOME (donc ni dépôt cloné, ni précompilé, ni backend
  custom, qui sont gérés par Loki) ; exposé en clé 'provided' de /api/llamacpp
- les routes install / install-custom / update refusent en 409 avec un
  message explicite au lieu de partir en build
- l'UI affiche l'état réel (chemin du binaire) et masque les trois modes

Le journal du moteur n'était repliable que par un second clic sur la pastille
d'état — sans chevron ni titre cliquable, contrairement à toutes les autres
sections de la barre latérale. Il devient un <details><summary> comme les
autres ; la pastille reste un raccourci et le chargement suit l'événement
toggle, quel que soit le moyen d'ouverture.

Vérifié dans l'image : provided=true, config_bin=/app/llama-server,
POST /api/llamacpp/install → 409 explicite, <details id="svc-log-box"> servi.
2026-08-15 07:49:46 +00:00
Claude e1f3191276 Merge branch 'claude/ajean-loki-container-fork-aep9r2' : correctif link + image officielle llama.cpp 2026-08-14 23:00:46 +00:00
Loki 1e232a7ff3 Simplification radicale : moteur pris de l'image officielle llama.cpp
Plus aucune compilation CUDA : le runtime part de
ghcr.io/ggml-org/llama.cpp:server-cuda (llama-server précompilé et maintenu
par l'équipe amont, backends .so chargés dynamiquement, archs GPU courantes,
repli CPU fonctionnel). Le build complet passe de ~40 min à ~4 min et tous
les pièges du build CUDA sans GPU (stubs libcuda, espace disque du runner,
choix des architectures) disparaissent.

- Dockerfile : 2 étapes (Go + image officielle), build-arg LLAMACPP_IMAGE
  pour épingler une version ou passer en CPU/Vulkan
- entrypoint : BIN=/app/llama-server
- workflow GHCR : purge disque et CUDA_ARCHS supprimés, cache max
- compose/.env/README à l'avenant

Validé : build 3 min 55, UI HTTP 200, healthcheck healthy, superviseur PID
OK, llama-server démarre (repli CPU) et n'échoue que sur un modèle factice.
2026-08-14 22:59:35 +00:00
Loki 0846a70eb0 Dockerfile : link llama-server sans GPU (--allow-shlib-undefined)
Le builder n'a pas de libcuda.so.1 (API driver) : le link de llama-server
échouait sur des références cu* non résolues. Comme le cuda.Dockerfile
officiel de llama.cpp, on autorise les symboles non résolus des .so au
link — le runtime NVIDIA injecte le vrai libcuda à l'exécution.
Ajout aussi des flags UI amont (LLAMA_BUILD_UI=OFF, LLAMA_USE_PREBUILT_UI=OFF).
2026-08-14 22:21:45 +00:00
LogiFlow d4fc5eeac0 Merge pull request #2 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Claude/ajean loki container fork aep9r2
2026-08-15 00:06:06 +02:00
Loki bb9aa9f559 Attribution du fork, README, logo UI et workflow GHCR
- NOTICE.md : Loki est un fork d'AJEAN (nathaninline, MIT), liste des
  modifications ; LICENSE amont conservée à l'identique.
- README réécrit : bandeau fork, architecture conteneur, démarrage Docker,
  procédure Unraid (plugin Nvidia Driver), différences avec l'amont.
- UI : le logo pixel-art épelle désormais LOKI (il épelait encore AJEAN),
  infobulle d'attribution sur la marque ; index.html régénéré.
- Workflow GHCR : libération d'espace disque du runner (l'étape CUDA devel
  ne tient pas dans les ~14 Go libres), build-args CUDA_ARCHS/LOKI_VERSION,
  cache GHA en mode min (plafond 10 Go).
2026-08-14 21:47:57 +00:00
Loki d5d0ed1f35 Conteneurisation : image CUDA autonome (moteur + UI, un seul conteneur)
- Dockerfile 3 étapes : llama.cpp CUDA (flags de backend_build.go, sauf
  GGML_NATIVE=OFF — l'image est bâtie sur un runner, pas sur la machine
  cible), binaire Go statique, runtime CUDA léger (tini, git, nodejs pour
  les serveurs MCP npx).
- docker-entrypoint.sh : sème BIN/HOST/PORT (+ MODEL/CTX/NGL depuis l'env,
  au premier boot seulement), lance le moteur en supervision PID puis
  'loki web' au premier plan. UI seule exposée (8090) ; le moteur (8080,
  non authentifié) reste interne.
- Nouvelle commande 'loki config [get|set]' : configuration non interactive
  (la config vit dans bbolt, pas dans un fichier plat).
- docker-compose.yml (build local, réservation GPU) + variante Unraid
  (image GHCR, runtime nvidia) ; volumes /data (LOKI_HOME) et /models.

Un seul conteneur car llm_client.go joint le moteur sur localhost (hérité
de l'amont).
2026-08-14 21:47:57 +00:00
Loki 6c4a8a3cf5 Supervision du moteur sans systemd (conteneur Docker)
Nouveau sys_service_container.go : supervision par fichier PID portée du mode
utilisateur macOS (Setsid, SIGTERM sur le groupe puis SIGKILL, log fichier).
sys_service_linux.go bascule automatiquement quand /run/systemd/system est
absent ou que LOKI_CONTAINER=1 ; une install systemd classique est inchangée.
Le tunnel ajean.link suit la même logique (uiServiceCtl / uiServiceActive).

Sans ce repli, changer de modèle depuis l'UI (serviceAction restart)
échouait sur un systemctl absent — le conteneur était inutilisable.

Testé sans systemd : start / status / restart / stop, PID suivi,
processus enfant arrêté avec le groupe. build linux+darwin+windows OK,
go vet + go test verts.
2026-08-14 21:37:51 +00:00
Loki de6551a153 Rebaptise AJEAN en Loki (fork, lignée conservée)
- module github.com/R0m1k3/Loki, cmd/loki, internal/loki (package loki)
- LOKI_HOME, LOKI_MODEL_DIRS, LOKI_SERVICE, LOKI_DL_CONNS ; /etc/loki ;
  units loki-engine / loki-ui ; binaire et aide CLI
- updateRepo pointe sur R0m1k3/Loki (l'auto-update ne tirera plus les
  binaires AJEAN amont)

Conservé à l'identique : le domaine ajean.link (service de tunnel amont),
les littéraux de migration 0.7.x (migrate_07.go), RELEASE_NOTES.md et
LICENSE (historique et licence de l'amont).

go build/vet/test : verts.
2026-08-14 21:33:15 +00:00
Loki 2164b46319 Retire la pile Python/React : Loki repart de la base Go d'AJEAN
L'ancien Loki (FastAPI + React + Ollama) est remplacé par le fork d'AJEAN.
Tout reste accessible dans l'historique (git show bb4fb1b:backend/app/agent.py).
2026-08-14 21:30:13 +00:00
Loki 9f1b0562cf Merge remote-tracking branch 'upstream/main' into claude/ajean-loki-container-fork-aep9r2
# Conflicts:
#	.gitignore
#	README.md
2026-08-14 21:29:24 +00:00
nathaninline aeca75c547 v0.9.4 : la vision configurable par preset (projecteur mmproj) et les images envoyées au modele 2026-08-14 15:11:08 +02:00
nathaninline d4989f2991 v0.9.3 : statut moteur plus précis + réglages d'interface
- statut : la pastille ne dit plus toujours « modèle incompatible » (prêt /
  chargement… / erreur / arrêté) ; le détail n'accuse l'incompatibilité que
  quand le journal le montre (quant/architecture), sinon renvoie au journal
- panneau Actions : bouton « refresh » retiré ; mise à jour + exporter sur une
  ligne, bench + clear chat sur l'autre
- modale preset : titre sans le mot « Preset » redondant (juste le nom)
- mémoire : « auto (proactive) » → « auto »
2026-08-14 11:20:18 +02:00
nathaninline e3957fa749 v0.9.2 : postes distants dans le chat + envoi de fichiers via ajean.link reparé
- UI : bouton PC/wifi dans la barre de saisie → modale « Postes distants »
  (cible d'exécution, liste, appairage) ; bloc retiré des réglages
- envoi de fichiers derrière ajean.link : sendChunk passe par jfetch (E2E) au
  lieu d'un fetch brut qui n'atteignait pas le serveur derrière le relais
- ajean remote install ré-exécutable : --code force l'appairage, et nodeclient
  retire le service en cours + remplace le binaire verrouillé (Windows/Linux)
- MAJ depuis l'UI : restartAfterUpdate en sudo -n quand le service tourne en
  non-root ; références ajean-link → ajean-ui ; hint de restart corrigé
2026-08-14 10:31:46 +02:00
nathaninline 88d1e79e80 v0.9.1 : le poste distant est integre a ajean (fini le binaire ajean-remote separe)
- nouvelle sous-commande `ajean remote install|connect|run|status|uninstall|logout`
  (reutilise internal/nodeclient), remplace le binaire cmd/ajean-remote supprime
- le service (Windows/systemd/launchd) lance desormais `ajean remote run`
- UI Postes distants : commande affichee `ajean remote install ...`
- CI release : retrait du cross-build des 6 binaires ajean-remote
2026-08-14 09:24:40 +02:00
nathaninline 1454dff2a7 v0.9.0 : poste distant accessible via ajean.link, chiffré de bout en bout (relais aveugle) 2026-08-11 15:29:36 +02:00
nathaninline 6d81f5c3f5 Retire un helper node inutilisé (staticcheck) 2026-08-11 15:02:05 +02:00
nathaninline 7fa5461136 v0.8.9 : piloter un autre PC depuis l'IA (poste distant ajean-remote) 2026-08-11 14:58:15 +02:00
nathaninline 6d36464971 Envois simultanes serialises, et pas de controle d'espace disque
Deux defauts trouves en relisant le code de l'envoi en morceaux.

Le verrou global etait tenu pendant l'ecriture disque et le renommage.
Or le client lance un envoi PAR FICHIER JOINT, de front : trois fichiers
s'attendaient donc les uns les autres, chacun bloquant les suivants
jusqu'a sa derniere tranche. Le verrou global ne protege plus que la
table des sessions ; l'ecriture se fait sous le verrou de la session. Un
drapeau `busy` empeche le menage de fermer un fichier sous les pieds de
la requete qui l'ecrit. Test : 3 fichiers de 20 Mo en parallele, contenus
distincts, aucun melange.

Rien ne verifiait la place disque : un envoi d'un gigaoctet pouvait
remplir le volume de la machine qui fait tourner le modele, ou vivent
aussi llama-server, la base et les journaux. Le client annonce la taille
au premier morceau, le serveur refuse en 507 s'il ne reste pas la place
plus 512 Mo de marge. La taille annoncee n'engage que le client : le
plafond reel reste verifie morceau par morceau.

Aussi : Sync() avant publication (un tampon non vide donne un fichier
incomplet), et cote client une tranche vide sans marque de fin arrete le
telechargement au lieu de boucler indefiniment.
2026-08-10 20:47:00 +02:00
nathaninline 78423276e7 Telecharger un fichier depuis ajean.link rendait du JSON
Le proxy chiffre (relay_e2e.go) reemballe TOUTE reponse en JSON avant de
la renvoyer : une reponse non-JSON est enveloppee en chaine. Du binaire
n'y survit pas — les octets non-UTF8 sont massacres — et le navigateur
telechargeait l'enveloppe JSON a la place du fichier. Le probleme est
structurel, pas propre au telechargement : il touchait aussi l'export
Markdown de conversation, qui revenait entre guillemets, echappements
compris.

handleChatFile prend donc deux formes supplementaires : `meta=1` rend une
fiche {name, size, e2e}, et `b64=1&offset=&len=` une tranche en base64.
Le client demande la fiche d'abord — minuscule, elle passe partout — et
en tire le transport a utiliser : binaire direct en local, tranches
base64 derriere le tunnel. Le client ne peut pas le deviner autrement, le
proxy lui rendant des reponses JSON parfaitement ordinaires.

Le decoupage en tranches de 8 Mo evite de tenir le fichier entier en
memoire pour le transporter, comme a l'envoi. Verifie sur 120 Mo : les
deux chemins rendent exactement les memes 125 829 120 octets, et le test
Go compare octet a octet sur des donnees non-UTF8, celles que le
reemballage JSON detruisait.

relay_e2e.go marque desormais les requetes venues du tunnel
(X-Ajean-E2E) : c'est le seul moyen pour un handler de savoir que sa
reponse sera reemballee.
2026-08-10 20:29:57 +02:00
nathaninline e07051e1e3 v0.8.8 : envoi de fichiers jusqu'a 1 Go, en morceaux
La limite de 24 Mo n'etait pas arbitraire : le fichier partait dans un
seul corps JSON en base64, tenu entier en memoire par le navigateur puis
par le serveur. Relever la constante seule aurait demande ~1,4 Go de RAM
de chaque cote pour un fichier de 1 Go.

Le transfert est donc decoupe en tranches de 8 Mo. Le navigateur lit une
tranche a la fois (Blob.slice), le serveur la decode, l'ecrit dans un
.part et l'oublie ; seul le descripteur reste ouvert. Mesure sur 120 Mo :
la RAM du process n'a pas bouge (95 -> 96 Mo).

uploadMaxBytes (1 Go) borne desormais le DISQUE ; c'est uploadChunkMax
(8 Mo) qui borne la memoire, et lui seul.

Une session interrompue est balayee au bout de 10 min, et les .part
orphelins sont effaces au demarrage du service : les sessions vivent en
memoire, aucun n'est reprenable apres un arret. Un identifiant inconnu
est refuse en 409 plutot que de reprendre un fichier en son milieu.

La vignette affiche l'avancement en pourcentage au-dela d'une tranche.
2026-08-10 20:05:01 +02:00
nathaninline 6807bad48d v0.8.7 : envoyer et recevoir des fichiers dans le chat
Les fichiers circulent enfin dans les deux sens.

Envoi : trombone dans le composeur, glisser-deposer sur toute la fenetre,
ou Ctrl+V. Le fichier est depose dans uploads/ du dossier de travail de
l'agent et son chemin est annonce au modele, qui en fait ce qu'il veut
avec ses outils. Rien n'est interprete au passage, donc tout type de
fichier passe. Le depot n'a lieu qu'a l'ENVOI du message : retirer un
fichier de la liste ne laisse aucune trace sur le disque. Transport en
base64 dans du JSON et non en multipart, seule forme que le tunnel E2E
dispatche : l'acces distant marche sans code specifique.

Reception : le modele ecrit un lien Markdown ordinaire vers un fichier de
son dossier de travail, et le clic telecharge. Il repondait jusqu'ici par
un chemin serveur, inutilisable depuis un navigateur. Le clic passe par
fetch + blob (la cle de pilotage voyage dans un en-tete qu'une navigation
ne porterait pas, et le chemin de base change derriere le tunnel).
Markdown refusant les espaces non echappes dans une cible de lien, ils
sont encodes avant rendu : sans ca [x](mon rapport.pdf) ne produisait
rien du tout.

Perimetre du telechargement strictement limite au dossier de travail :
'..', chemins absolus et liens symboliques qui en sortent sont refuses,
et le fichier est toujours servi en piece jointe avec nosniff — un .html
produit par le modele ne doit pas s'executer dans l'origine de l'UI, ou
il lirait la cle de pilotage.

Preambule du mode agent : ~2350 -> ~1770 tokens, sans perdre une regle.
Le poids etait dans les schemas d'outils (le double du prompt), pas dans
le prompt. Celui-ci reenumerait des outils que les schemas decrivent
deja, et la regle « write, jamais echo » y figurait trois fois. Trois
tests gardent le budget : ce prompt a deja regrossi ligne par ligne deux
fois.

Aussi : l'export mentionne les pieces jointes (un message sans texte
donnait une section vide), et le decompte de contexte perd le mot
« contexte » au profit du nom du modele.
2026-08-10 17:11:56 +02:00
nathaninline 5a4c33661d v0.8.6 : changer la cle de pilotage ne coupe plus l'acces distant
Le tunnel lisait la cle une seule fois a son ouverture et injectait
l'ancienne apres tout changement : l'acces distant tombait en 401
jusqu'au redemarrage du service, avec pour seul symptome un
« chargement de la conversation » infini.
2026-08-08 21:32:52 +02:00
nathaninline 59eb2357e9 L'acces distant suit la cle de pilotage courante
withLocalAuth capturait la cle UNE FOIS a l'ouverture du tunnel.
Changer la cle ensuite, depuis l'interface ou avec set-web-key, faisait
donc injecter l'ANCIENNE dans chaque requete venue du relais : tout
l'acces distant passait en 401 jusqu'au prochain redemarrage du
service. Et comme le chat n'affiche pas le code HTTP d'un flux qui
n'arrive jamais, le symptome etait un « chargement de la conversation »
infini, sans une ligne d'explication.

La cle est relue a chaque requete. Cout nul : requireWebAuth le fait
deja de l'autre cote. Deux tests, dont un qui echoue bien sur l'ancien
code.
2026-08-08 21:26:41 +02:00
nathaninline 0ad961d75f v0.8.5 : le chat ne s'arrete plus en silence, et l'export se regle vraiment
Correction principale : un flux de reponse coupe en cours de route
etait indiscernable d'une fin normale (sc.Err() jamais consulte), donc
le tour se refermait sans un mot pendant que le moteur affichait un
« stop processing » normal. Issue #19. Un appel d'outil a moitie recu
pouvait meme etre execute avec des arguments tronques.

Fenetre d'export refaite : memes options de contenu quel que soit le
format, portee au curseur borne au nombre reel d'echanges, etat « rien
a exporter » sur un fil vide, style de l'editeur de preset. « clear
chat » passe en derniere position.

Aussi : la memoire n'est plus annoncee au modele sous MEMORY/ (issue
#20), et l'ecartement du binaire pendant une mise a jour ne peut plus
produire deux fois le meme nom.
2026-08-08 21:18:29 +02:00
nathaninline 42cdb2cda3 Le flux de reponse coupe ne s'arrete plus en silence (issue #19)
sc.Scan() rend false aussi bien a la fin normale d'un flux qu'a la
premiere erreur de lecture, et sc.Err() n'etait JAMAIS consulte. Une
connexion reinitialisee en plein milieu, ou une ligne plus longue que
le tampon, etait donc indiscernable d'une fin propre : le tour
s'arretait sans un mot pendant que le journal de llama-server affichait
un « stop processing » parfaitement normal. C'est le symptome rapporte
— l'agent enchaine quelques commandes puis rend la main sans avoir
termine ni commente.

Pire : des appels d'outils accumules a moitie auraient ete EXECUTES
avec des arguments tronques. On abandonne desormais le tour en nommant
la cause, le texte deja recu reste affiche, et un stop volontaire reste
silencieux. Trois tests couvrent les trois cas ; celui du flux coupe
echoue bien sur l'ancien code.

Tampon du scanner porte de 1 a 8 Mio au passage : la cause la plus
probable d'une ligne trop longue est un appel d'outil demesure, et
jusqu'ici il partait dans le meme silence.

Sans rapport, trouve en faisant tourner la suite : renameAside tirait
son nom du seul UnixNano, dont la granularite Windows peut rendre deux
fois la meme valeur — deux ecartements se disputaient alors le meme
nom, le second ecrasant le binaire mis de cote par le premier. Un
compteur atomique les departage.
2026-08-08 21:07:15 +02:00
nathaninline 4a66b27227 Export : memes options quel que soit le format, curseur simple, etat vide
Trois choses ne tenaient pas debout dans la fenetre.

Les options CHANGEAIENT selon le format : le JSON proposait un obscur
« journal d'affichage » a la place des trois cases du Markdown.
Personne ne pouvait deviner ce que ca recouvrait, et cocher une case
n'avait pas le meme sens selon le format choisi juste au-dessus. Les
trois memes options valent desormais pour les deux ; le format ne
decide que du contenant. Cote JSON, le journal est filtre par les
memes criteres.

La taille du fichier annoncee sous le curseur ne servait a rien et
justifiait a elle seule une sonde cote serveur. Supprimee, sonde
comprise : le nombre d'echanges vient maintenant de /api/chat/state,
qui est deja l'instantane leger de la conversation.

La fenetre reprend le vocabulaire visuel de l'editeur de preset
(modal-card, pe-group, pe-row, interrupteurs pe-switch) au lieu de son
balisage bricole, et chaque option porte son sous-titre. Conversation
vide : un simple « Rien a exporter » remplace les reglages, plutot que
d'offrir de tailler un fichier qui serait vide de toute facon.
2026-08-08 20:56:11 +02:00
nathaninline b7042f1127 Fenetre d'export : portee au curseur, format en segments
Le menu de portee proposait « 25 derniers echanges » sur un fil qui en
comptait trois, et ne disait nulle part combien il y en avait. Un
curseur le remplace, borne au nombre REEL d'echanges, butee droite =
toute la conversation. Sous la piste : le compte (« 3 derniers echanges
sur 12 ») et le POIDS du fichier pour les options courantes, sonde
cote serveur (probe=1, rendu puis jete, donc jamais un second calcul de
taille qui divergerait).

Le format passe en controle segmente (.seg), celui du choix de moteur
dans l'editeur de preset : deux cases cote a cote au lieu de deux
boutons radio empiles.

Deux details qui faussaient l'affichage : une sonde encore en vol
revenait ecraser le « … » par la taille de l'etat PRECEDENT (le poids
retardait d'un cran sur le curseur), et fmtSize arrondissait 2585
octets en « 3 KB » dans le seul endroit qui pretend annoncer la taille
reelle.
2026-08-08 20:39:58 +02:00
nathaninline 7a27dbf3be Export : une fenetre d'options, et le vidage du chat passe en dernier
Deux boutons d'export cote a cote ne laissaient aucun choix : tout ou
rien. Un bouton unique ouvre desormais une fenetre ou l'on choisit le
format (Markdown a lire / JSON a retraiter), ce qu'on emporte
(raisonnements, appels d'outils, sorties d'outils, ou journal
d'affichage cote JSON) et la portee (tout le fil ou les N derniers
echanges). Une ligne dit ce que l'export contiendra, et l'en-tete du
fichier signale un export partiel : relu plus tard, il ne doit pas
passer pour le fil complet.

Memes options en ligne de commande : --last N, --no-reasoning,
--no-tools, --no-results, --messages-only. Sans option, l'export reste
complet, endpoint compris.

« clear chat » descend en derniere position et seul sur sa ligne :
c'est la seule action destructive du panneau, elle n'a rien a faire
sous le doigt qui visait « refresh ».

Au passage, issue #20 : le prompt systeme et la description de l'outil
mem_search annoncaient encore la memoire sous MEMORY/, nom d'avant la
0.8. Le modele lisait donc un chemin qui n'existe plus. Corrige, avec
les deux commentaires restes au meme nom.
2026-08-08 20:25:18 +02:00
nathaninline d881895301 Le drapeau de neutralisation du pare-feu passe cote commun
Declare dans sys_firewall_windows.go, il n'etait qu'ECRIT par les tests
sur les autres plateformes : staticcheck le signalait mort (U1000) et la
CI cassait. Il vit desormais dans sys_network.go, ou setLANExposure le
lit sur les trois plateformes.
2026-08-08 20:05:18 +02:00
nathaninline 52baab5786 v0.8.4 : l'historique s'exporte, le moteur se laisse joindre, les modeles en plusieurs fichiers marchent
Trois retours d'utilisateurs apres la 0.8.3.

Export de la conversation (regression de la 0.8 : conversation.json a
disparu dans ajean.db, verrouille et binaire). Bouton dans l'UI +
commande 'ajean export [--json] [fichier]', endpoint /api/chat/export.

Ecoute reseau du moteur : interrupteur 'joignable depuis le reseau local'
dans le panneau Acces OpenAI (HOST + regle de pare-feu Windows), commande
'ajean network [on|off|status]'. HOST devient un reglage de machine
preserve au changement de preset.

Modeles GGUF decoupes en tranches -00001-of-000NN : une famille = un
modele. Telechargement de toutes les tranches depuis n'importe quel lien,
sonde d'espace disque sur le total, liste et suppression regroupees,
demarrage refuse avec le nom du fichier manquant.
2026-08-08 20:01:54 +02:00
nathaninline acf11ff66a v0.8.3 : le stop arrete vraiment, le chat ne se bloque plus, raisonnement off = off
Bug 1 (chat bloque, remonte par Nathan) : trois causes cumulees.
- runShell naissait d'un contexte independant du tour : stop n'arretait rien.
  La commande herite desormais du contexte du tour, et la boucle d'outils
  s'interrompt des que l'arret est demande.
- cmd.WaitDelay : une commande laissant un process detache gardait les tubes
  ouverts, donc Wait ne revenait JAMAIS (ni delai, ni stop).
- Reset debloque toujours l'etat de generation : « nouvelle conversation » etait
  le geste tente quand le chat coince, il ne servait a rien. Un tour perime ne
  libere plus le tour courant.
Trouve en testant : le workspace disparu cassait TOUTES les commandes suivantes
(chdir: no such file or directory) jusqu'au redemarrage ; il est recree au besoin.

Bug 2 (raisonnement, remonte par Nathan) : l'UI ecrivait « off » en EFFACANT la
ligne REASONING, or sans drapeau le moteur suit le gabarit du modele, qui
raisonne. L'UI ecrit maintenant on|off et « off » part en --reasoning off, apres
avoir demande au binaire s'il connait le drapeau (ik_llama.cpp ne le connait pas
et refuserait de demarrer). Presets existants inchanges ; l'editeur dit desormais
« non precise : le modele decide » quand la cle est absente.
2026-08-08 11:20:22 +02:00
nathaninline e6a948c314 v0.8.2 : audit du coeur, correctifs de securite et de robustesse
- Le mode agent redevient une vraie fermeture : une surcharge de requete ne peut
  plus RALLUMER l'agent (donc bash/write/edit/MCP) quand la machine l'a coupe.
- Une lecture ratee de la cle de pilotage ne se confond plus avec « aucune cle » :
  requireWebAuth ferme (503) au lieu d'ouvrir l'API.
- healthCheck : timeout de 3 s. Sans lui, un moteur qui accepte la connexion sans
  repondre laissait /api/chat/send bloque indefiniment.
- Acces distant : la session de tunnel ecoute enfin un contexte. Un redemarrage ne
  fait plus cohabiter deux sessions ; le backoff se remet a zero apres une session
  qui a tenu.
- Flux SSE : selection des evenements neufs par dichotomie au lieu de relire tout
  le journal a chaque token et pour chaque appareil.
- Cache de lecture de la configuration, invalide par ecriture locale, par mtime et
  par age : fin des ~100 ouvertures de base par tour agentique.
- Erreurs moteur classees par type (errors.Is / net.Error) et non par sous-chaine
  anglaise ; les 3 compactions partagent un seul chemin ; champs morts de chatReq
  retires ; ReadHeaderTimeout sur les deux serveurs HTTP.
2026-08-08 10:56:41 +02:00
nathaninline bc270ac15c Suppression de trimSplit, devenu mort avec splitArgs (staticcheck U1000) 2026-08-08 09:20:00 +02:00
nathaninline db47c72240 v0.8.1 : le moteur dit enfin pourquoi il ne demarre pas, et les presets repartent propres
- start/restart : preflight (BIN, MODEL, .gguf) et fin du faux « [ok] activating »
  sur un service qui redemarre en boucle (SubState auto-restart).
- MODEL en chemin absolu accepte hors des dossiers declares (lecture seule) ;
  l'allowlist reste sur la suppression de fichiers.
- ajean edit : squelette commente des cles au lieu d'un fichier quasi vide.
- install : les prochaines etapes n'oublient plus « ajean llamacpp install ».
- Presets : un nouveau preset ne recopie plus la config active (issue #17),
  seulement BIN/HOST/PORT.
- Presets : plus de guillemet interne mange a la relecture (Go et interface),
  et EXTRA_ARGS decoupe en respectant les guillemets.
2026-08-08 09:16:16 +02:00
nathaninline 68d6d2032a Windows : version de fichier, fenetres de console et remplacement d'un meme build
Trois defauts signales a l'usage.

1. Le binaire 0.8.0 se DECLARAIT 0.7.13 a Windows : j'avais bump la constante Go
   sans toucher a cmd/ajean/versioninfo.json ni regenerer les .syso. D'ou le
   « version en cours 0.7.13 / ce fichier 0.8.0 » a chaque relance du fichier
   telecharge. Ressource remise a 0.8.0, .syso regeneres, et un test garde
   desormais les deux en phase.

2. Des fenetres de console apparaissaient : la relance apres installation et
   l'ouverture du navigateur creaient un enfant sans masquer sa fenetre, ce qui
   se voit maintenant que le binaire est en sous-systeme console. Les deux
   passent par hideCmd. Les raccourcis DEJA poses recoivent en plus le demarrage
   minimise, qui ne s'appliquait qu'aux nouveaux.

3. Deux builds de meme version n'etaient jamais remplaces : relancer un
   telechargement plus recent d'une preversion republiee demarrait simplement
   l'ancienne copie. On compare desormais les fichiers (taille puis SHA-256) et
   non les seuls numeros.

Au passage, l'icone est epinglee sur le raccourci : Windows garde parfois en
cache celle d'un binaire remplace et affiche un carre blanc.
2026-08-07 22:30:01 +02:00
nathaninline 6bf304573c Correctif : l'application ne demarrait plus du tout au double-clic
ShowWindow etait cherchee dans kernel32 alors qu'elle vit dans user32.
LazyProc.Call PANIQUE quand la procedure est introuvable, et setupConsole
s'execute avant tout le reste : le programme mourait sur le champ, sans rien
installer et sans rien afficher. Seul le chemin du double-clic etait touche
(console creee pour nous), d'ou une CI verte et des tests CLI qui passaient.

Tous les appels Win32 de ce fichier passent desormais par un helper qui verifie
la presence de la procedure au lieu de paniquer : aucun reglage de confort ne
justifie d'empecher le programme de demarrer.

Verifie de bout en bout cette fois : lancement facon double-clic -> arborescence
creee, bin/ajean.exe installe, aucune panique, processus vivant ; et les chemins
CLI (ordre d'affichage, redirection, tube, aide sans argument) inchanges.
2026-08-07 22:09:43 +02:00
nathaninline aa965227fc Windows : sous-systeme console, pour qu'ajean se comporte en vraie commande
Dans un terminal, cmd rendait la main avant qu'ajean n'ait rien ecrit : l'invite
se reaffichait puis la sortie arrivait par-dessus. Windows n'attend jamais la fin
d'un programme en sous-systeme graphique. La meme cause empechait les
redirections et les tubes, et rendait 'ajean chat' inutilisable (le shell et
ajean se disputaient l'entree).

Le binaire passe donc en sous-systeme console. La fenetre noire au double-clic,
que le sous-systeme graphique evitait, est traitee autrement : quand aucun autre
processus ne partage notre console, c'est que Windows l'a creee pour nous, donc
que personne ne nous a lances depuis un terminal. On la masque et on la libere.
Les raccourcis demandent en plus un demarrage minimise.

La relance interne (apres mise a jour) passe desormais 'app' explicitement : la
detection ne repond qu'a la question du double-clic, et un processus sans
console du tout ne doit pas avoir a y repondre.

Verifie : ordre d'affichage correct en session cmd, redirection et tube
fonctionnels, 'ajean' sans argument affiche l'aide, et aucune fenetre console
visible au lancement facon double-clic.
2026-08-07 21:59:42 +02:00
nathaninline 922b7a6862 Notes de release : redirection et tubes sous Windows 2026-08-07 21:40:15 +02:00
nathaninline 4dc7848301 Windows : la redirection et les tubes fonctionnent enfin
'ajean where > fichier.txt' n'ecrivait rien dans le fichier et 'ajean help |
findstr x' ne transmettait rien : tout partait sur la console.

Cause : le binaire etant en sous-systeme GUI, setupConsole reouvrait stdout,
stderr et stdin sur CONOUT$/CONIN$ pour avoir ou ecrire. Il ecrasait ce faisant
la redirection que le shell avait posee. Le test naif ne suffit pas :
AttachConsole REMPLACE lui-meme les handles standard par ceux de la console,
donc les interroger apres le rattachement ne montre jamais qu'une console. On
releve donc leur type AVANT d'attacher, et on ne reouvre que ceux qui sont
reellement inutilisables.

Verifie sur un binaire compile comme celui de la release (sous-systeme GUI) :
redirection vers un fichier, tube vers findstr, et affichage console inchange.
2026-08-07 21:38:57 +02:00
nathaninline 7cea41c2a5 Notes de release : les deux changements Windows 2026-08-07 21:24:50 +02:00
nathaninline e0dcbe715a Windows : plus de question a la premiere installation
Le double-clic demandait « Installer AJEAN ou juste le lancer ? ». La question
n'avait qu'une reponse utile : rester a l'emplacement du fichier telecharge ne
donne pas une installation utilisable (aucun raccourci, rien dans le PATH, et
une application qui disparait le jour ou l'on vide ses telechargements). Faire
porter a l'utilisateur un choix qui n'existe pas ne l'aide pas.

L'installation se fait donc directement, et le message qui suit dit ce qui a ete
fait : ou le programme s'est installe, et que le fichier telecharge peut etre
supprime. Le repli en cas d'echec (droits sur %ProgramData%) est inchange.

Les deux autres boites de dialogue sont conservees : elles portent de vraies
decisions (fichier plus ancien que la version installee, fermer une application
en cours pour appliquer une mise a jour).
2026-08-07 21:12:35 +02:00
nathaninline 672317d68c Windows : « Quitter » arrete aussi le moteur
Le moteur tourne dans un process detache, qui survit volontairement a la
fermeture de l'interface pour garder le modele charge entre deux ouvertures.
Mais apres un « Quitter », plus rien ne le pilote et il conserve des dizaines de
Go de RAM sans aucune fenetre pour l'expliquer. La fonction de sortie du systray
appelle desormais svcStop, qui tue l'arbre (taskkill /T) donc llama-server avec.

Windows uniquement : c'est la seule plateforme ou l'app est proprietaire du
moteur. Sous Linux et macOS il appartient a systemd ou launchd, et fermer une
interface n'a pas a arreter un service systeme.

Le redemarrage apres mise a jour ne passe PAS par ce chemin (il a son propre
os.Exit), donc une MAJ ne dechargera pas le modele.
2026-08-07 21:08:20 +02:00
nathaninline 351ee25ad9 Zips macOS en minuscules : ajean-macos.zip, ajean-macos-arm.zip
Ils prennent simplement le nom du binaire correspondant. Effet de bord corrige
au passage : tous les assets commencent desormais par « ajean- », donc le motif
« ajean-* *.zip » de SHA256SUMS aurait liste les zips DEUX fois, et un fichier
d'empreintes a doublons est precisement ce que verifie 'ajean update'.
2026-08-07 20:45:35 +02:00
nathaninline 479da7b832 gofmt : ligne de separation avant la directive lint 2026-08-07 20:37:53 +02:00
nathaninline 43ab55f083 staticcheck : exception documentee pour brandTemplatePNG
Elle n'est utilisee que par le tray macOS, que staticcheck ne compile pas hors
CGO. Sans cette directive, la CI (qui lance staticcheck sur ubuntu) echouerait
sur une fonction pourtant bien utilisee.
2026-08-07 20:35:34 +02:00
nathaninline eac12112dd Correctifs 0.8 : saisie de la cle de pilotage, identites appairees perdues a la migration
Deux defauts trouves a l'usage :

1. Impossible de saisir la cle de pilotage dans l'UI : le chargement lance une
   dizaine d'appels /api/* en parallele, chaque 401 rouvrait la modale, et
   celle-ci vide son champ a l'ouverture. Les caracteres disparaissaient au fur
   et a mesure de la frappe. Une seule demande de cle est desormais partagee par
   tous les appels concurrents. Au passage, une modale ouverte n'est plus
   ecrasee en silence (sa promesse restait pendante pour toujours).

2. migrate_07 n'importait pas .authorized_users : les identites appairees pour
   le chat chiffre. Symptome trompeur — le tunnel s'etablit, la machine
   s'affiche en ligne, mais le portail refuse toute conversation. Importe et
   couvert par un test.
2026-08-07 20:34:43 +02:00
nathaninline 98ee293a49 Correction du build macOS casse par le nettoyage
brandTemplatePNG n'etait pas du code mort : sys_tray_darwin.go l'utilise pour
l'icone de barre de menus. Ce fichier ne compile qu'avec CGO, donc ni la
compilation croisee depuis Windows ni staticcheck sans CGO ne le voyaient — la
fonction passait pour inutilisee. Restauree.

sys_service_darwin.go affichait encore le chemin de config.env, disparu avec le
passage en base.

Le piege est documente dans doc.go : seul le job macos de la CI attrape ce genre
de regression.
2026-08-07 20:00:36 +02:00
nathaninline f1c0917276 Notes de release conformes, SKILLS archive a la migration
- notes reecrites sans tiret cadratin (convention du projet), avec la marche a
  suivre d'installation et la section de ce qui n'a pas ete teste
- migrate_07 archive aussi SKILLS/ (dossier mort depuis la fusion dans memory)
- commentaire d'en-tete de release.yml remis a jour
2026-08-07 19:38:36 +02:00
nathaninline 071caea516 Noms de release lisibles, et incompatibles avec l'updater 0.7
ajean-linux, ajean-linux-arm, ajean-macos, ajean-macos-arm,
ajean-windows.exe, ajean-windows-arm.exe.

Le schema ne correspond plus a ajean-<GOOS>-<GOARCH> que cherchent les 0.7 :
leur mise a jour automatique ne trouve aucun asset et echoue sans rien
remplacer. C'est voulu — sinon elle installait le binaire 0.8 sans migrer, le
service de lien repartait en boucle d'echec sur « link serve » disparu et le
moteur perdait sa configuration.

assetNameFor est isolee pour etre testee sur les six plateformes, plus un test
qui verifie explicitement l'absence de collision avec le schema 0.7.
2026-08-07 19:30:26 +02:00
nathaninline 664b13753b Migration 0.7 automatique dans 'ajean install'
Un seul fichier, migrate_07.go, sans dependance entrante : le supprimer et
retirer les deux appels suffira le jour venu. Declenchee UNIQUEMENT par install
(root, delibere) et non au demarrage, contrairement a ce qui existait en 0.7 et
qui pouvait laisser une machine distante sans service.

Deplace configs/->presets/, MEMORY/->memory/, les .gguf->models/, reprend l'etat
en base, range les anciens fichiers dans avant-0.8/ et desactive les unites jean*
(sans quoi un second llama-server demarrait au boot suivant).

Trois tests : le cas nominal, l'installation neuve, et la machine deja migree a
la main. Ils ont trouve deux bugs reels de casse de fichiers (MEMORY/memory se
confondent sur Windows et macOS).
2026-08-07 19:16:55 +02:00
nathaninline 35edc631a9 Correctif du worker detache, outil de migration 0.7 dans le depot, CI staticcheck
- uiUserSvcCtl lancait encore « ajean link serve », sous-commande supprimee :
  l'acces distant etait casse sur macOS et Windows (hors systemd)
- tools/migrate-0.7 : reprise d'une installation 0.7.x, hors du binaire pour ne
  pas reintroduire de compat dans le produit ; procedure complete au README
- CI : staticcheck ajoute (le projet est a zero avertissement, il doit le rester)
- .gitignore purge de ses entrees mortes (jean, config.env, .api_key...)
2026-08-07 18:07:33 +02:00
nathaninline 5daf6fb0eb Sudoers nommes d'apres l'unite (ajean-engine, ajean-ui) 2026-08-07 17:59:46 +02:00
nathaninline 1aabc0f9c7 Installateurs Unix fusionnes, staticcheck sans avertissement
- sys_install_unix.go porte le parcours commun (arborescence, lien du binaire,
  /etc/default, chown) ; Linux et macOS ne gardent que leur declaration de
  services derriere installServices/uninstallServices (395 -> 339 lignes, et
  surtout une seule copie a corriger)
- l'installateur macOS pose desormais les DEUX services (il n'en posait qu'un)
- API bbolt depreciee remplacee, message d'erreur duplique factorise
- staticcheck.conf : ST1005 ecartee, avec la raison (messages francais montres
  a l'utilisateur). Le projet est a zero avertissement.
2026-08-07 17:57:51 +02:00
nathaninline 7ecd869da0 Etat residuel en base, carte du package et docs remises d'aplomb
- sysprompt, utilisateurs appairés, codes d'appairage et etat du job de build
  passent en base ; ne restent des fichiers que la cle E2E, les certificats et
  les journaux (le log de build reste un fichier : des milliers de lignes)
- doc.go remis a jour (run.go, store.go, les deux services, tools/assemble-ui)
- README et notes de release ne pretendent plus que la racine ne contient que
  six dossiers : c'etait faux
2026-08-07 17:51:59 +02:00
nathaninline 29bfe236cd go mod tidy : bbolt en dependance directe 2026-08-07 17:48:05 +02:00
nathaninline 46d1b9c8cd UI : panneau Emplacements aligne sur la nouvelle arborescence
Le champ 'config' de /api/paths est devenu 'database' : la ligne Configuration
s'affichait vide. Le panneau montre desormais la base, les modeles, les presets,
la memoire, le workspace et les backends.
2026-08-07 17:44:53 +02:00
nathaninline 24ff2f0a25 Deux services nommes ajean-engine et ajean-ui, une seule porte web
- ajean.service -> ajean-engine, ajean-link.service -> ajean-ui
- 'ajean web' est desormais l'unique serveur d'interface : il ouvre aussi le
  tunnel quand un jeton est enregistre (une seule conversation, plus de conflit
  sur :8090 ni de fils divergents)
- 'ajean link' ne gere plus que le compte ; 'ajean ui' pilote le service
- l'installateur Linux pose les deux unites et les deux regles sudoers
- 'app' sort de l'aide
2026-08-07 17:39:46 +02:00
nathaninline 47c27499ae Base ouverte a la demande, pas gardee ouverte
bbolt pose un verrou exclusif sur son fichier : le service de lien, qui tourne
en permanence, empechait toute commande CLI de s'executer a cote. La base est
desormais ouverte pour la duree d'une operation puis refermee, et les buckets
ne sont crees qu'a l'ecriture (une lecture ne coute plus un fsync : 3,8 ms ->
0,55 ms). Test de concurrence a l'appui.
2026-08-07 17:22:31 +02:00
nathaninline 687ec81023 v0.8.0 : code mort supprime, UI et docs en ajean, release unique
- suppression des fonctions et types inutilises (staticcheck U1000)
- sources UI, workflows, README passes en ajean
- release.yml ne publie plus que les binaires ajean-*
- notes de release redigees
2026-08-07 17:12:16 +02:00
nathaninline 62ad4155a2 CLI elaguee et tests remis sur la base
- suppression des alias de commandes herites (skills, machine, tools, mem, upgrade...)
- aide reecrite et regroupee
- helper de test testHome + fermeture de la base (verrou bbolt sous Windows)
- tests des dossiers de modeles alignes sur models/
2026-08-07 17:03:27 +02:00
nathaninline 6de356f649 Arborescence unifiee et installateurs simplifies
- provisionDataDir unique pour les trois plateformes (six dossiers, config par defaut)
- plus de modele config.env duplique dans chaque installateur
- plus d'alias jean pose a l'installation
2026-08-07 16:57:52 +02:00
nathaninline 2455d9f402 Purge du code de compatibilite jean, base bbolt et arborescence ajean
- suppression des migrations d'agencement, d'unites et de dossier de donnees
- store bbolt unique (ajean.db) : config, prefs, conversation, cles, drapeaux
- arborescence backends/ bin/ presets/ memory/ models/ workspace/
- plus aucune variable, unite ou asset nomme jean
2026-08-07 16:51:36 +02:00
nathaninline 081a88c846 v0.7.13 : selection de preset instantanee, envoi bloque tant que le modele charge
- Preset actif : liseré gauche droit, rogné par l'arrondi de la carte, un peu plus large
- Puce de l'actif dessinée en CSS (même taille et même assiette sur Windows et macOS)
- Survol et sélection moins appuyés dans la liste
- Bascule : retour visuel immédiat (barre qui glisse depuis la gauche, atténuée puis
  pleine) ; animations rejouées seulement quand la sélection change vraiment
- /api/switch répond sans attendre le redémarrage du service : bascule instantanée
- Bouton envoyer et touche Entrée désactivés tant que le modèle n'est pas chargé
2026-08-06 11:59:41 +02:00
nathaninline 742c14544a v0.7.12 : plus rien ne bouge apres le chargement 2026-08-05 21:59:30 +02:00
nathaninline baff643a9c Memoire : ligne sans chevron, libelle centre, libelles de l'editeur corriges 2026-08-05 21:57:43 +02:00
nathaninline 8d92db6e33 Modale : couper les transitions differees au retour a l'ecran 2026-08-05 21:52:54 +02:00
nathaninline 76d0d16062 Modale : voile de chargement opaque sous l'en-tete 2026-08-05 21:50:23 +02:00
nathaninline d1ffc97dc3 Menu : reserver la hauteur des blocs charges par le reseau 2026-08-05 21:45:34 +02:00
nathaninline 0dcb4690c3 Section Moteur : plus de saut de hauteur au chargement de l'etat 2026-08-05 21:39:32 +02:00
nathaninline a63a66bef8 Corrige la position d'ouverture de la modale et le saut des champs Crawl4AI 2026-08-05 21:36:51 +02:00
nathaninline d15d40eb7c Modale preset : ouverture en haut et bloc moteur stabilise, pastilles redondantes retirees 2026-08-05 21:29:33 +02:00
nathaninline f3769299b9 v0.7.11 : le menu parle enfin la meme langue partout 2026-08-05 21:21:16 +02:00
nathaninline fd758ce4f8 v0.7.10 : la mise a jour Windows arrete d'accuser les droits
- ecartement du binaire sous un nom unique : un reste verrouille par un ancien
  processus ne fait plus echouer le remplacement (message "droits insuffisants,
  relance en administrateur" qui etait faux, aucun privilege ne remplace une
  image en cours d'execution)
- section Cartes graphiques masquee pour de vrai avec une seule carte :
  l'attribut hidden etait sans effet, .pe-group{display:flex} l'emportait
2026-08-05 17:07:05 +02:00
nathaninline ae38aca9ba MAJ Windows : ne plus echouer sur un ecartement encore verrouille
Le remplacement du binaire renommait l'ancien en un nom FIXE (.old). Si un
processus tournait encore depuis ce .old (mise a jour precedente), le fichier
n'etait ni supprimable ni ecrasable : le renommage sortait en "Acces refuse"
(errno 5), que os.IsPermission rapporte comme un defaut de DROITS. L'UI
affichait donc "droits insuffisants, relance en administrateur", conseil faux :
aucun privilege ne remplace l'image d'un executable en cours d'execution.

- renameAside() ecarte sous un nom UNIQUE (.old-<horodatage>) : le renommage
  passe meme si un ancien ecartement est verrouille (verifie sur banc d'essai
  avec deux processus vivants)
- les trois endroits qui faisaient la manoeuvre l'utilisent (update, install,
  premier lancement), et le menage ramasse le nom fixe herite + les uniques
- message d'erreur : Windows renvoie le meme code pour "droits insuffisants" et
  "fichier utilise", les deux causes sont donc nommees

Aussi : la section Cartes graphiques restait affichee avec une seule carte.
L'attribut hidden etait sans effet, .pe-group{display:flex} l'emportant sur la
regle par defaut du navigateur.
2026-08-05 17:03:43 +02:00
nathaninline e57fc0966e v0.7.9 : les moteurs llama.cpp arretent de jouer a cache-cache
- moteur precompile : la version installee est celle qui tourne (on elisait le
  dossier le plus ancien), et les anciennes extractions sont purgees
- presets : le moteur precompile reste reconnu apres une mise a jour, et un BIN
  pointant une release disparue suit la version installee au lieu d'echouer
- build CUDA : le toolkit est cherche avant le nvcc du PATH, sa racine est
  passee a CMake (fin du "CUDA Toolkit not found" avec CUDA installe)
- job d'installation : etat persiste sur disque, une operation interrompue par
  un redemarrage du service est annoncee, pastille de rappel sur mobile
- editeur de modele : section Cartes graphiques (--device / --tensor-split), la
  liste vient du moteur du preset car les identifiants dependent du backend
- reglages : plus de menu replie, interrupteurs regroupes, decodage speculatif
- CUDA_VISIBLE_DEVICES et WEB_ENGINE survivent au changement de preset, sauf si
  le preset les definit lui-meme
2026-08-05 16:51:10 +02:00
nathaninline 726f065d25 UI : nommer les moteurs web Moteur Integre et Crawl4AI, sans suffixe 2026-08-05 12:00:30 +02:00
nathaninline d604398f73 Moteur web integre : passer les murs de consentement
Cookie jar en memoire (les murs posent un cookie puis redirigent), en-tetes
DNT et Sec-GPC, et cookies marquant le bandeau comme traite. Les valeurs
envoyees signalent un REFUS du pistage, jamais une acceptation : le but est
de lire la page, pas d'ouvrir des traceurs au nom de l'utilisateur.

Mesure : reuters.com passe de HTTP 401 a 2318 caracteres extraits, aucune
regression sur lemonde/lefigaro/20minutes/theguardian/spiegel/wikipedia.
2026-08-05 11:53:18 +02:00
nathaninline 3dba323aab v0.7.8 : l'acces internet sans rien installer
Moteur web integre en pur Go (http + readability + html-to-markdown),
selectionnable par WEB_ENGINE. Crawl4AI reste disponible pour le rendu
JavaScript et reste le defaut des installations qui l'ont deja configure.

Le schema de web_open n'annonce plus actions/wait_for/dismiss_popups avec
le moteur integre, et une page riche en HTML mais sans texte est diagnostiquee
comme rendue en JavaScript, pour que le modele change de source au lieu de
rouvrir la meme URL en boucle.
2026-08-05 11:48:45 +02:00
nathaninline 9dedba0e47 v0.7.7 : la recherche internet ne tourne plus en rond
Le compactage du contexte detruisait ce dont l'IA avait besoin pour continuer
une recherche, ce qui la faisait boucler ou repartir de zero.

- Compactage : le resume est desormais fabrique a partir du torse ORIGINAL. On
  degraissait les resultats d'outils (page web -> marqueur) AVANT de resumer :
  le resumeur ne voyait plus aucune information trouvee, donc le resume n'en
  contenait aucune et l'IA relancait les memes recherches sans fin. Les
  resultats sont seulement raccourcis (1200 car.) pour le resumeur ; le torse
  degraisse ne sert plus que de repli si le resume echoue.
- Compactage : la demande EN COURS est preservee telle quelle. Pendant une
  boucle d'outils il n'y a aucun message user recent, elle tombait donc dans le
  torse et se dissolvait dans le resume, tandis que le TOUT PREMIER message de
  la conversation restait epingle en tete : l'IA repondait a la question du
  debut et abandonnait la recherche.
- Compactage : prompt du resumeur refait (findings, sources deja consultees,
  etat d'avancement) et plafond du resume porte de 1500 a 2200 caracteres.
- Appels repetes : l'avertissement passe EN TETE du resultat et, des la 2e
  redemande identique, le contenu n'est plus renvoye. Colle a la fin d'un
  resultat de plusieurs milliers de caracteres, il passait inapercu.
- Marqueur d'effacement du compactage : dit explicitement de ne PAS rappeler
  l'outil (l'ancien texte se lisait comme une invitation a re-telecharger).
- Outils web : sortie bornee a 8000 caracteres comme le shell. Un
  web_read(limit=500) pouvait injecter 25000 caracteres d'un coup et declencher
  les compactages en cascade.
- Bulles d'outils : la borne d'affichage passe de 4000 a 12000 caracteres, donc
  l'etiquette « ~N tok » cesse d'afficher toujours ~1004 (la valeur du plafond)
  et donne la vraie taille. Le chemin « appel identique non rejoue » envoyait
  lui le resultat brut sans troncature.
2026-08-04 22:28:19 +02:00
nathaninline aa92d2ab49 Notes de release 0.7.6 : recapituler toute la ligne 0.7
Les 0.7.0 a 0.7.5 etant retirees, la 0.7.6 devient la seule porte de
sortie depuis la 0.6.15. Ses notes doivent donc decrire le renommage
complet et pas seulement le dernier correctif : commande jean conservee,
migration automatique du dossier, redemarrage par le bouton, unites
systemd inchangees, disparition du dossier SKILLS.
2026-08-04 17:42:00 +02:00
nathaninline e730c1ef49 v0.7.6 : ne plus creer le dossier SKILLS
Les skills ont ete fondus dans la memoire, mais les trois chemins
d'installation (Windows, Linux, macOS) creaient toujours un dossier SKILLS
vide.

On cesse de le CREER sans cesser de le LIRE : migrateSkillsToMemory reprend
encore une fois les anciens skills d'une installation existante, et il faut
donc que skillsDir() continue de pointer quelque part. La fonction ne fait
rien quand le dossier est absent.

Rien n'est supprime : un dossier SKILLS existant reste en place, son contenu
ayant deja ete repris dans la memoire.

Verifie sur une installation neuve en bac a sable : plus aucun SKILLS cree.
2026-08-04 17:38:08 +02:00
nathaninline d72578ea55 v0.7.5 : le bouton de mise a jour redemarre l'application (Windows)
Un process ne peut pas se relancer lui-meme : tant qu'il tourne, il occupe le
port et tient son propre dossier de donnees. On delegue donc a un
accompagnateur detache — une copie de nous lancee avec une sous-commande
interne — qui attend notre disparition, migre pendant cette fenetre, puis
relance l'application.

C'est cette fenetre qui manquait depuis le debut. Une mise a jour en place
laisse AJEAN tourner, donc le dossier tenu, donc le renommage echoue avec
« Acces refuse » — que Windows renvoie aussi bien pour un verrou que pour un
manque de droits. D'ou des postes bloques sur l'ancien nom malgre 0.7.1 a
0.7.4. Le retour terrain le confirme : la migration n'a abouti qu'en lancant
l'exe telecharge, seul chemin qui arretait les instances avant de migrer.

installedExePath() est relu APRES la migration avant de relancer : le binaire
installe vit dans le dossier de donnees, il change donc de place avec lui, et
relancer l'ancien chemin echouerait. Repli sur le binaire courant si la cible
a disparu — mieux vaut relancer quelque chose que laisser l'utilisateur sans
application.

L'attente est bornee a 30 s. Un depassement n'interrompt pas la suite : la
migration echouera peut-etre et sera retentee plus tard, alors que rester
coince laisserait l'utilisateur sans rien.

Verifie de bout en bout sur banc d'essai : l'accompagnateur attend la
fermeture du process cible, se termine proprement, et l'application est bien
relancee et ressert l'UI.
2026-08-04 17:31:06 +02:00
nathaninline 1f773151a6 v0.7.4 : migrer le dossier depuis le chemin de mise a jour (Windows)
L'elevation ajoutee en 0.7.3 n'etait jamais atteinte. Elle n'etait cablee que
dans `install` et dans l'installeur de mise a jour (double-clic sur l'exe
telecharge) ; or les utilisateurs font `ajean update` ou cliquent le bouton de
l'interface. restartAfterUpdate ne fait rien sous Windows, et au redemarrage
appFirstRun sort avant d'arriver la (l'application se reconnait comme etant
deja l'installee). Aucun poste ne migrait donc jamais.

postUpdateMigrate est appele apres une mise a jour reussie, cote CLI comme
cote UI web.

Une hypothese a ete invalidee au passage, et elle changeait tout : on croyait
qu'un binaire en cours d'execution empechait de renommer le dossier qui le
contient. C'est faux — Windows verrouille le fichier .exe, pas le nom de ses
dossiers parents. Verifie sur banc d'essai. La migration peut donc se faire
depuis l'application en marche, ce qui rend ce chemin possible.

Consequence non anticipee, traitee ici : le binaire installe vit DANS le
dossier de donnees. Le renommer deplace donc le programme, et laisse un
raccourci du menu Demarrer et une entree de PATH pointant dans le vide.
relinkAfterMigration repointe le raccourci, ajoute le nouveau dossier bin au
PATH et retire l'ancien.

Course de donnees corrigee : retryHomeMigration reinitialisait le cache du
dossier resolu sans verrou, alors qu'il est desormais appele depuis un handler
HTTP pendant que le serveur sert d'autres requetes. sync.Once est remplace par
un RWMutex, et les reinitialisations directes des installeurs Unix passent par
resetResolvedHome.
2026-08-04 17:16:17 +02:00
nathaninline 8365447de0 v0.7.3 : demander l'elevation Windows quand elle debloque la migration
Un compte non administrateur ne peut pas renommer C:\ProgramData\jean : le
droit manquant porte sur C:\ProgramData. Ces postes gardaient donc l'ancien
nom indefiniment. Les 0.7.1 et 0.7.2 avaient traite le bruit, pas le fond.

L'installation relance desormais AJEAN en administrateur le temps de la
SEULE migration (sous-commande interne, absente de l'aide), puis rend la
main. Un refus de la fenetre UAC est une reponse valable : on continue sur
l'ancien chemin, sans rien casser.

Deux tentatives de discrimination ont echoue avant celle-ci, et le detail
compte pour qui relira ce code :

- Le code d'erreur ne distingue rien. Windows renvoie ERROR_ACCESS_DENIED
  aussi bien pour un manque de droits que pour un dossier verrouille par un
  programme en cours. Un test le prouve, il avait ete ecrit en croyant
  l'inverse.
- Sonder l'ecriture dans le dossier parent ne distingue rien non plus :
  ProgramData autorise la creation de fichiers aux utilisateurs standards,
  c'est la suppression/renommage d'une entree existante qui est refusee.

C'est donc le LIEU D'APPEL qui selectionne, et lui est fiable : on n'eleve
que depuis `install` et depuis l'installeur de mise a jour, tous deux apres
l'arret des instances. Le cas « verrouille » y est deja ecarte.

Note pour les tests : ne JAMAIS appeler elevateForHomeMigration() sous
`go test`. os.Executable() y designe le binaire de test, que l'elevation
relance — donc toute la suite, en boucle. Constate.
2026-08-04 17:03:34 +02:00
nathaninline 7a141d19b5 v0.7.2 : l'alias jean.exe et les raccourcis restaient sur une version perimee
Regression introduite par l'alias de la 0.7.0, signalee en usage : « Une
version plus recente d'AJEAN est deja installee » a CHAQUE lancement, sans
moyen d'en sortir.

Deux defauts qui se renforcaient.

installLegacyAlias utilisait copyExe, qui echoue quand jean.exe est en cours
d'execution — Windows refuse d'ecraser un .exe actif. L'echec etait ignore
volontairement (« sans gravite, la copie repassera au prochain lancement »),
ce qui etait faux : l'alias restait fige indefiniment. replaceExe applique
desormais la technique deja utilisee pour le binaire principal — renommer
l'ancien, ecrire le nouveau, restaurer si la copie echoue. installSelf s'en
sert aussi, pour la meme raison.

ensureShortcuts ne touchait jamais un raccourci existant. Ceux crees avant la
0.7.0 visaient donc toujours jean.exe. Chaque lancement executait l'alias
perime, qui voyait ajean.exe plus recent et le signalait — la boucle. Un
raccourci existant est maintenant repointe s'il vise autre chose que le
binaire courant.

cleanupOldBinary balaie aussi le jean.exe.old que le nouveau remplacement
peut laisser.

Deux tests : remplacement d'un exe verrouille, et garantie que la cible ne
disparait jamais si la copie echoue apres le renommage.
2026-08-04 16:50:39 +02:00
nathaninline b4d2d5ed02 v0.7.1 : ne plus afficher l'echec de migration a chaque commande
Remonte par un utilisateur Windows non administrateur. Renommer
C:\ProgramData\jean exige d'ecrire dans C:\ProgramData, ce qu'un compte
standard ne peut pas faire : le rename echouait a chaque lancement et le
message technique s'affichait avant CHAQUE commande, y compris `ajean help`.

Rien n'etait casse — le repli sur l'ancien chemin fonctionne comme prevu et
aucune donnee n'est en jeu. Le defaut etait entierement dans l'affichage :
un detail interne presente comme une erreur, sur une situation permanente et
sans consequence.

La raison est desormais enregistree au lieu d'etre imprimee, et ressortie la
ou elle est actionnable : `ajean where`, qui parle justement d'emplacements,
avec la marche a suivre. `ajean install` en administrateur retente la
migration — c'est le seul moment ou les droits sont reunis — et le fait AVANT
de resoudre le dossier, sinon la variable resterait sur l'ancien chemin et on
installerait a cote.

La cause du rename est extraite du *os.LinkError : son texte repete les deux
chemins complets, ce qui donnait trois fois les memes chemins dans un message
qui les cite deja et noyait l'essentiel (« Acces refuse »).

Deux tests : l'echec reste silencieux mais enregistre, et aucun message
parasite quand la migration reussit. Verifie sur un cas reproduit — `help`
est propre, `where` affiche le conseil.
2026-08-04 16:40:22 +02:00
nathaninline 2b20158e7c CI : chemins cmd/jean -> cmd/ajean oublies dans les workflows
Le renommage des dossiers avait ete repercute dans release.yml mais pas
dans ci.yml, qui compilait encore ./cmd/jean : les deux jobs echouaient.
Une occurrence trainait aussi dans mac-build.yml (cmd/jean/icon.ico), que
le remplacement precedent n'avait attrapee que pour icon.png.

Un cas n'etait pas qu'un commentaire : tools/assemble-ui utilisait
"internal/jean/ui" comme chemin par defaut. La directive go:generate passe
le dossier en argument, donc la generation fonctionnait, mais l'invocation
directe documentee dans son propre en-tete (go run ./tools/assemble-ui)
aurait echoue.

Le reste etait des commentaires designant les anciens chemins, corriges
pour que le depot ne mente plus sur sa propre structure.

Job ci rejoue en entier en local : gofmt, vet, build, tests et les quatre
cross-compilations de release.
2026-08-04 16:31:43 +02:00
nathaninline 75929e7b60 Migration automatique de l'agencement systeme (Unix)
Sans ca, le renommage n'atteignait pas les machines Linux installees. Le
dossier de donnees y est epingle par /etc/default/jean, que notre propre
installeur ecrit ; le code traitant un chemin explicite comme un ordre, il
ne migrait jamais. Constate en deployant 0.7.0 sur un vrai serveur : les
chemins sont restes en /etc/jean et il a fallu un script separe.

La regle qui debloque : distinguer NOTRE artefact du CHOIX de l'utilisateur.
Si le chemin epingle vaut exactement l'ancien emplacement par defaut, c'est
notre installeur qui l'a ecrit et on migre. Toute autre valeur est une
decision de l'utilisateur et reste intacte.

Le dossier, /etc/default et l'unite systemd bougent ENSEMBLE : les separer
donnerait le pire cas, des donnees deplacees et un service qui ne redemarre
plus. Les chemins sont reecrits par parcours du dossier plutot que d'apres
une liste devinee — l'experience montre qu'ils se cachent dans EXTRA_ARGS,
les copies .bak et jusque dans des notes de MEMORY. backends/ et les .gguf
sont sautes.

Deux moments de declenchement, tous deux root et deliberes : `install`, et
juste apres une mise a jour reussie. Jamais au demarrage ordinaire — un
service qui reecrit des unites systemd a chaque boot finirait par en casser
une, et sur un parc entier ca veut dire des machines qui ne redemarrent plus.

La variante post-mise-a-jour n'arrete AUCUN service : on s'execute dans
jean-link, l'arreter reviendrait a se tuer au milieu de la migration, dossier
deplace et chemins pas encore reecrits. Sans danger, un rename de dossier
sous Linux tolere les fichiers ouverts et un process garde son repertoire de
travail par son inode.

Sauvegarde prealable bloquante, et retour a l'etat initial si une etape
echoue apres le deplacement. Couvert par 6 tests sur un faux /etc, dont le
respect d'un chemin impose et la garantie qu'aucun service n'est touche par
la variante post-MAJ.
2026-08-04 16:23:20 +02:00
nathaninline aa452f9799 v0.7.0 : Jean devient AJEAN
Bump de version et notes de release. Le renommage complet jean -> AJEAN
est decrit dans RELEASE_NOTES.md ; les commits precedents en portent le
detail technique.
2026-08-04 15:59:41 +02:00
nathaninline a58103ff7e Migration : reecrire aussi les chemins sous leur forme JSON echappee
Trouve en rejouant la migration dans un bac a sable isole. Les fichiers
JSON du dossier de donnees (model_dirs.json, mcp.json, webprefs.json) sont
ecrits par json.Marshal : sous Windows chaque antislash y est DOUBLE. La
reecriture ne cherchait que la forme native et la forme a slashs, elle
laissait donc ces fichiers pointer vers l'ancien dossier.

Pire, la fonction d'echappement etait ecrite avec des litteraux bruts et
remplacait en fait un antislash par lui-meme : elle ne doublait rien. Le
remplacement natif matchait alors la forme JSON et produisait un fichier
invalide (« invalid character 'U' in string escape code »), c'est-a-dire la
liste des dossiers de modeles perdue. Un bug pire que celui qu'on corrigeait.

jsonEscapePath utilise desormais des litteraux entre guillemets, avec un
commentaire explicite sur le piege ("\\" est UN antislash, "\\\\" en fait
deux). Test dedie qui verifie que le chemin est bien reecrit ET que le JSON
reste analysable apres coup.

Verifie en bac a sable sur les trois ecritures simultanement (native,
slashs, JSON echappe) : les trois sont reecrites, le JSON se reparse et
pointe sur le nouveau dossier.
2026-08-04 14:33:03 +02:00
nathaninline 8a2b5c6687 Migration : reecrire les chemins absolus pointant vers l'ancien dossier
Bug trouve en testant la migration sur une vraie machine (5,4 Go, 51 351
fichiers). Le rename du dossier reussissait, mais config.env contenait

  BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe

soit un chemin ABSOLU vers le dossier qu'on venait de renommer. Resultat :
le dossier migre intact, et llama-server ne demarre plus. Ca touche tous
ceux qui ont fait un `llamacpp install`, donc le cas nominal, pas un cas
limite. Les presets de configs/ portent le meme BIN et etaient touches
pareil (deux presets concernes sur la machine de test).

Apres un rename reussi, on reecrit donc les references a l'ancien chemin
dans les fichiers de config qu'on ecrit nous-memes : config.env,
model_dirs.json, mcp.json, webprefs.json et les presets de configs/. Les
deux ecritures de separateur sont couvertes, Windows acceptant \ et / dans
une meme valeur. Restreint a ces fichiers texte : on ne reecrit pas a
l'aveugle les 51 000 fichiers d'un dossier de donnees.

Les fichiers d'etat du service sont repris au passage (jean.pid/jean.log ->
ajean.pid/ajean.log), meme raison que pour le worker de lien : le PID dit
si le service tourne, l'ignorer ferait demarrer un second service.

Non traite volontairement : l'arborescence de build llama.cpp
(CMakeCache.txt et consorts) contient aussi des chemins absolus. Elle est
regenerable par `ajean llamacpp update` et la reecrire en masse serait plus
risque qu'utile.

Verifie de bout en bout sur la machine de test : service demarre,
llama-server chargé depuis le nouveau chemin, /health ok, completion reelle
ok. Deux tests ajoutes reproduisant le cas.
2026-08-04 14:24:53 +02:00
nathaninline 993be881d5 Renommage AJEAN (3/3) : textes, aide, README et variables d'environnement
Surface visible passee en AJEAN : aide de la CLI, messages, invite du chat
terminal, commentaires, README, metadonnees Windows du binaire
(InternalName / OriginalFilename).

Les reglages s'appellent desormais AJEAN_* (AJEAN_HOME, AJEAN_MODEL_DIRS,
AJEAN_DL_CONNS, AJEAN_LINK_URL, AJEAN_ACME_EMAIL, AJEAN_SERVICE), avec
l'ancien nom JEAN_* toujours lu en second via envAny(). Ces variables sont
posees a la main dans des shells, des units, des docker-compose et des
scripts de deploiement qu'on ne peut pas retrouver : cesser de les lire
ferait retomber silencieusement un reglage sur sa valeur par defaut.

Migration one-shot du dossier de donnees retentee dans la fenetre ou
l'installateur Windows a arrete toutes les instances, seule fenetre ou le
rename peut aboutir. Le chemin du binaire est recalcule juste apres, sinon
on reinstallerait dans le dossier qu'on vient de deplacer.

Deux choses volontairement NON renommees, parce que les renommer casserait
des utilisateurs sans rien apporter :

- "model": "jean", le nom expose sur l'endpoint compatible OpenAI. Des
  integrations externes le configurent en dur.
- les cles localStorage (jean-theme, jean.sys...). Les renommer
  reinitialiserait theme, preferences d'affichage et prompt systeme dans le
  navigateur de chaque utilisateur.

Le plist launchd recevait /usr/local/bin/jean en dur : le chemin est
desormais injecte, sinon le daemon macOS aurait refuse de demarrer apres le
renommage.

index.html regenere depuis ui/src via go generate.
2026-08-04 14:16:12 +02:00
nathaninline 3720b90173 Renommage AJEAN (2/3) : services, binaire et releases
Suite du renommage. Principe directeur : le code s'adapte a ce qui est
DEJA installe sur la machine, plutot que d'exiger une migration
privilegiee qui pourrait echouer et laisser un serveur sans service.

Services (sys_unitname.go). Une mise a jour remplace le binaire, pas les
unites systemd : un serveur deja installe tourne encore sous jean.service
et jean-link.service. serviceName() et linkServiceName() resolvent donc
le nom a l'usage — l'unite qui existe reellement — au lieu de supposer
"ajean*". Sans ca, le premier systemctl restart apres MAJ echouerait, et
sur un serveur distant ca veut dire l'acces coupe sans terminal pour
reparer. Meme logique pour le label launchd, ou le prefixe com.jean. est
conserve tant que c'est lui qui est charge.

Le fichier PID du worker de lien est repris au passage (jean-link.pid ->
ajean-link.pid) : c'est lui qui dit si un worker tourne, et sans reprise
la premiere version renommee en aurait demarre un second, donnant deux
tunnels concurrents vers le relais.

Binaire. installedExePath vise desormais ajean(.exe), et l'installation
pose un alias "jean" a cote — lien symbolique sous Unix, copie sous
Windows ou les liens demandent des droits qu'on n'a pas toujours. La
commande d'hier reste tapable : on ne controle ni les alias shell, ni les
cron, ni les scripts de deploiement, ni les tutos deja ecrits.

/etc/default/ajean est ecrit avec AJEAN_HOME ET JEAN_HOME, et l'ancien
/etc/default/jean est laisse en place plutot que supprime : des scripts
d'utilisateurs le sourcent.

Releases. Les workflows produisent ajean-<os>-<arch> puis en copient un
jeu jean-<os>-<arch> identique, une fois les binaires macOS recuperes.
Les deux jeux sont dans SHA256SUMS. Ces copies sont indispensables : le
parc installe ne sait chercher que jean-*, il ne pourrait donc meme pas
atteindre la version qui apprend a lire ajean-*. A retirer quand le parc
aura bascule. Le bundle devient AJEAN.app, avec le zip publie sous les
deux noms pour ne pas casser les liens de telechargement deja diffuses.
La detection du mode application se basant sur /Contents/MacOS et non sur
le nom du bundle, elle n'est pas affectee.
2026-08-04 14:07:44 +02:00
nathaninline 110458c133 Renommage AJEAN (1/3) : module, chemins de donnees et auto-MAJ
Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc
installe. Trois briques :

- module github.com/nathaninline/ajean, cmd/ajean, internal/ajean,
  package ajean. Purement interne.

- AjeanHome() remplace JeanHome(). Les defauts deviennent
  %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant
  au premier lancement. La migration est un os.Rename dans le dossier
  parent de l'ancien chemin : intra-volume, donc atomique et instantane
  meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue
  (handle ouvert sous Windows, droits, volume en lecture seule) on
  CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais
  de suppression, le pire cas est "rien n'a bouge".
  $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que
  /etc/default/ajean et /etc/default/jean : un chemin impose a la main
  est un ordre, on ne le migre pas.

- l'auto-MAJ accepte desormais ajean-<os>-<arch> ET jean-<os>-<arch>,
  dans cet ordre. Indispensable dans les deux sens : les binaires deja
  installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir
  s'installer depuis une release anterieure au renommage.

Couvert par des tests, dont le repli quand un handle ouvert bloque le
rename sous Windows.
2026-08-04 13:59:25 +02:00
nathaninline c1d58b19f2 v0.6.15 : un refus d'installation n'est plus definitif
Le drapeau .install_declined (0.6.11) supprimait la question POUR TOUJOURS :
apres un Non, plus aucun moyen d'installer AJEAN, meme en relancant le fichier
telecharge. Un refus vaut « pas maintenant » : la question revient au prochain
lancement, et la fenetre l'annonce.

Verifie au banc d'essai : refus, relance, question reposee, acceptation,
installation + raccourcis + demarrage.
2026-08-04 13:34:28 +02:00
nathaninline 26ce8a9a14 v0.6.14 : creer le dossier bin a la premiere installation
Sur une machine vierge, le premier lancement du fichier telecharge echouait sur
« open C:\ProgramData\jean\bin\jean.exe: The system cannot find the path
specified » : appFirstRun creait le dossier de donnees mais pas son sous-dossier
bin, cree jusqu'ici par le seul `jean install`. AJEAN demarrait alors depuis le
fichier telecharge, sans installation ni raccourcis.

MkdirAll deplace dans installSelf, donc valable quel que soit l'appelant.
Reproduit puis verifie sur banc d'essai Windows (parcours complet depuis un
dossier de donnees inexistant), + test unitaire.
2026-08-04 13:24:42 +02:00
nathaninline 0e94f1e9e2 v0.6.13 : chemins Windows canonises avant comparaison
Correction du defaut trouve au banc d'essai Windows : voir 5728abf. Notes de
release + bump de version.
2026-08-04 12:50:00 +02:00
nathaninline 5728abf93e Windows : comparer les chemins canonises, pas les chaines brutes
Windows expose le meme fichier sous plusieurs ecritures (forme courte 8.3
« ADMINI~1 » contre « Administrateur », casse, liens). Les deux comparaisons de
chemins du premier lancement le supposaient identique des deux cotes :

- runningPIDs ratait l'instance en cours -> on remplacait le binaire en croyant
  l'application arretee, elle continuait de tourner en ancienne version sans que
  rien ne l'indique. C'est le bug corrige en 0.6.12, revenu par une autre porte.
- appFirstRun : l'application installee se serait prise pour une copie
  telechargee et se serait relancee, en BOUCLE INFINIE.

Constate sur banc d'essai Windows (JEAN_HOME sous un chemin en forme courte).
2026-08-04 12:37:59 +02:00
nathaninline 1d58a7b1b9 v0.6.12 : le .exe telecharge gere le cas « AJEAN deja lance » et les retours en arriere
- si AJEAN tourne, remplacer le binaire ne changeait RIEN a l'ecran (l'ancienne
  version restait en memoire) et rien ne le disait. On demande desormais s'il
  faut fermer et redemarrer pour appliquer la mise a jour, versions affichees.
- si la version installee est PLUS RECENTE que le fichier lance : avertissement,
  aucun remplacement, choix entre demarrer et fermer. Avant, une version plus
  ancienne prenait la place sans un mot.
- version installee illisible (binaire anterieur aux ressources de version) :
  traitee comme ancienne donc remplacable. La traiter comme egale condamnait ces
  installations a ne plus jamais se mettre a jour, en silence.
- raccourcis verifies a chaque demarrage et recrees s'ils manquent, et ranges
  dans le dossier « Programmes » du menu Demarrer (ils etaient a la racine, donc
  introuvables par la recherche). La desinstallation nettoie les deux endroits.
- les instances en cours sont reperees par CHEMIN complet, jamais par nom
  d'image : un taskkill /IM jean.exe emporterait le processus courant.
2026-08-04 12:01:13 +02:00
nathaninline 0d9284e95c v0.6.11 : le .exe telecharge met a jour et demarre, emplacements dans le journal
- Windows : quand AJEAN est deja installe, lancer le fichier telecharge remplace
  le binaire installe (s'il est plus recent) puis demarre l'application, sans
  message. La 0.6.10 annoncait « vous lancez une copie » puis demarrait quand
  meme : une information inactionnable suivie d'un comportement non annonce.
  Le refus d'installation est desormais memorise (.install_declined).
- la version du binaire installe est lue dans les ressources du fichier
  (VS_VERSIONINFO). L'implementation evidente (executer `jean version` et lire
  la sortie) renvoie du VIDE : le binaire est en sous-systeme GUI et n'ecrit pas
  dans un tuyau, donc la mise a jour ne se serait jamais faite, en silence.
  Piege documente dans le code + deux tests.
- UI : les emplacements passent sous le journal du moteur (clic sur la pastille
  d'etat) au lieu d'un bouton isole dans les actions.
2026-08-04 11:39:25 +02:00
nathaninline f541d997d2 Notes de release : suppression des tirets cadratins 2026-08-04 11:27:30 +02:00
nathaninline 1f3961eaa9 v0.6.10 : dossier de travail pour l'agent, lancement Windows explicite, mise à jour lisible, icône noire
- mode agent : les outils write/edit/bash résolvent les chemins relatifs dans
  JEAN_HOME/workspace et le shell y démarre, au lieu du répertoire courant du
  processus (Bureau, ProgramData\jean\bin). Les chemins absolus demandés
  explicitement restent honorés.
- Windows : le double-clic n'installe plus en silence. Boîte de dialogue unique,
  raccourcis menu Démarrer + Bureau, puis relance depuis la copie installée — un
  seul binaire fait foi. Désinstallation : retrait des raccourcis.
- update : contrôle des droits AVANT le téléchargement (l'échec tombait sur le
  fichier temporaire, avec un message brut), message donnant la commande exacte,
  timeout UI porté de 30 s à 10 min.
- jean where + bouton « Où sont mes fichiers ? » : emplacements, et alerte quand
  le binaire lancé n'est pas celui installé.
- icônes : source unique (sys_brand_icon.go) pour la zone de notification, la
  barre de menus macOS et l'icône du .exe, alignée sur le favicon noir. macOS en
  icône template (une icône noire figée disparaît sur une barre sombre).
- CI : le bundle macOS part de icon.png au lieu de convertir le .ico via sips,
  dont l'échec était masqué par « || true » ; notes de release versionnées.
2026-08-04 11:17:43 +02:00
nathaninline 6e7fe7d4a6 README : nouvelle accroche 2026-08-03 17:48:10 +02:00
nathaninline d50a01b404 README : formulations de l'introduction 2026-08-03 17:35:06 +02:00
nathaninline e0297ddea6 README : suppression des tirets cadratins dans le texte et de la phrase de trop sur llama.cpp 2026-08-03 17:32:37 +02:00
nathaninline 658de68d78 README : vouvoiement et repositionnement — AJEAN est une application d'IA locale complete, llama.cpp n'est que le moteur 2026-08-03 17:30:06 +02:00
nathaninline 0e6763920b README : remise à jour et simplification, nouvelle capture d'écran
Le README avait pris du retard sur les versions 0.5 et 0.6. Ajouts : serveurs
MCP (format mcp.json compatible Claude Desktop, transports stdio et HTTP),
compactage automatique du contexte, commandes app et oai, téléchargement de
modèles depuis l'UI.

Corrections : le mode agent donne désormais terminal, écriture/édition de
fichiers, mémoire, web et MCP — pas seulement « shell + mémoire ». La section
Windows ne parle plus de « jean tools ». Tableaux de config et de variables
d'environnement complétés (COMPACT, CRAWL4AI_KEY, JEAN_MODEL_DIRS, HF_TOKEN,
JEAN_DL_CONNS).

Structure resserrée : commandes regroupées par usage plutôt que par ordre
historique, sections longues raccourcies sans perte d'information.
2026-08-03 17:26:49 +02:00
nathaninline 7b0a5dbf75 v0.6.9 : l'IA écrit enfin les fichiers directement
Nouvel outil write : créer ou réécrire un fichier passe par le disque, plus
par le shell. Sous Windows le shell est cmd.exe et son quoting cassait tout
script contenant des guillemets imbriqués (echo, python -c, base64) ; l'IA
bouclait sans jamais y arriver. Signalé par Arnaud Hocquelet.

L'outil bash annonce désormais le vrai shell de la plateforme (cmd.exe sous
Windows) au lieu de dire bash partout, pour que le modèle arrête de générer
la syntaxe d'un autre shell.

Interface : le contenu d'une écriture se remplit ligne à ligne en direct dans
la bulle (diffusé à la ligne et pas au token, pour ne pas saturer le rendu
mobile), et les compteurs +N -N passent sur l'étiquette pour rester visibles
une fois la bulle repliée.
2026-08-03 15:12:18 +02:00
nathaninline 73a772520c v0.6.8 : les modeles peuvent vivre ailleurs que dans le dossier jean
Un .gguf pose sur un disque externe etait invisible dans l'UI : la liste
des modeles ne scannait que JEAN_HOME et un preset pointant ailleurs
affichait « introuvable ». On peut desormais declarer des dossiers de
modeles supplementaires (replies derriere une ligne dans l'editeur de
preset, persistes dans model_dirs.json, ou via JEAN_MODEL_DIRS).

Acces distant : la pastille affiche « non connecte » au lieu de rester
vide quand le serveur n'est lie a aucun compte.
2026-08-03 11:58:00 +02:00
nathaninline acf094f961 v0.6.7 : plus de toast fantome « rien a compacter » a chaque rafraichissement
Au chargement, l'UI rejoue tout le journal de la conversation pour reconstruire
le fil. L'evenement `compact_noop` y est persiste : il etait donc rejoue comme
n'importe quel message et redeclenchait son toast a CHAQUE ouverture de page,
avec une info qui datait parfois de plusieurs heures.

Confusion entre deux natures d'evenements : une notification ponctuelle, qui n'a
de sens qu'en direct, et une trace du fil, qui doit etre rejouee. Le toast est
desormais tu pendant le replay. La marque « contexte compacte », elle, continue
d'etre rejouee — c'est tout son interet.
2026-07-31 01:05:28 +02:00
nathaninline 3fd04a8304 web : le toast 'rien a compacter' ne se rejoue plus a chaque rafraichissement 2026-07-31 00:41:17 +02:00
nathaninline a727394115 v0.6.6 : le compactage automatique du contexte marche vraiment
Le compactage ne se declenchait qu'au DEBUT d'un tour, avec le contexte du
tour precedent. Le tour qui faisait franchir le seuil se terminait donc a
81% sans rien declencher : la jauge restait haute, seul le bouton manuel
apparaissait, et le compactage automatique semblait mort.

- seuil reteste a trois moments au lieu d'un : debut de tour (inchange),
  PENDANT la boucle d'outils, et EN FIN de tour des que le seuil est franchi
- une compaction faite en cours de tour etait perdue : l'appelant
  reconstruisait l'historique en ajoutant les messages du tour a sa copie
  d'AVANT compaction. Le fil complet revenait, la jauge retombait a 43% puis
  explosait a 92% au message suivant. Elle publie desormais sa nouvelle vue
  (StreamEvent.NewHistory), qui REMPLACE l'historique. Meme correction pour
  le filet de secours sur erreur 500 et pour le client terminal.
- la queue protegee reculait jusqu'au message `user` precedent. Pendant une
  longue recherche il n'y en a aucun : la queue avalait toute la sequence
  d'outils, torse vide, compaction sans effet — les pages web qui remplissent
  la fenetre etaient justement celles qui etaient protegees. Elle peut
  maintenant demarrer sur un `assistant`, frontiere tout aussi sure (il part
  avec ses resultats d'outils). Test de regression ajoute.
- une compaction sans effet ne disait plus rien : toast explicite + une ligne
  [compact] par decision dans le journal (ctx, seuil, avant/apres, changé)

UI : la mini-banniere au-dessus de la saisie doublonnait les trois points
animes de l'IA. L'indicateur existant est desormais etiquete « compactage du
contexte… » en accent, et une marque discrete reste DANS le fil a l'endroit
de la coupure — elle survit au rafraichissement, contrairement au toast.
2026-07-30 22:17:40 +02:00
nathaninline ecb05fcb86 gofmt : alignement des champs du client HTTP de telechargement 2026-07-30 15:16:55 +02:00
nathaninline 065264a055 v0.6.5 : telechargement de modeles bien plus rapide, barre de progression et annulation
- Telechargement d'un .gguf en 8 connexions paralleles (requetes Range,
  ecriture en place avec WriteAt) au lieu d'un seul flux TCP : sur le CDN
  Hugging Face le gain est typiquement de 3 a 6x. Repli automatique sur le
  flux unique si la source ne gere pas les ranges.
- Client HTTP dedie (pool de connexions reutilisees, HTTP/2 desactive) et
  reprise par tranche en cas de coupure. Reglable via JEAN_DL_CONNS.
- Barre de progression integree dans la modale de preset, avec vitesse et
  temps restant.
- Bouton d'annulation : coupe vraiment le telechargement et supprime le
  fichier partiel. Fermer la modale ne l'interrompt plus, l'UI se rebranche
  dessus a la reouverture.
- Les .gguf.part orphelins (crash, restart du service) sont nettoyes au
  demarrage.
- La liste de selection du modele s'ouvre alignee a gauche et sur toute la
  largeur de la ligne.
2026-07-30 15:06:01 +02:00
nathaninline 1360eb1943 v0.6.4 : serveurs MCP connectes en parallele et prechauffes, vitesse de generation de retour, retouches du menu
MCP : les serveurs actifs se connectent en parallele au lieu de la file
indienne (quatre serveurs npx n'additionnent plus leurs demarrages) et sont
prechauffes au lancement du service, donc le premier message ne paie plus le
handshake. Une session est publiee dans le pool avant d'aboutir : deux
appelants simultanes partagent la meme tentative au lieu de lancer deux
process.

Interface : la vitesse de generation (prefill/decode) reapparait sous chaque
reponse, en fondu, avec un interrupteur pour la masquer ; infobulles " ? "
qui n'etaient plus rognees ; espace superflu en haut des cartes du mode
agent ; pastilles d'etat redessinees et plaquees a droite ; moteurs renommes
en llama.cpp precompile / compile / personnalise ; badge conseille lisible en
sombre ; section Service fondue dans Moteur ; plus qu'un enregistrement
automatique pour l'acces internet.

Documentation : le README dit desormais que Crawl4AI est un serveur a
heberger soi-meme, avec la commande Docker.
2026-07-30 10:21:09 +02:00
nathaninline 92c350bdb8 v0.6.3 : texte non coupe autour des outils, garde-fous d'outils retires, retours UI
- Flux : le reliquat retenu par la garde « </think> » est vidé dès qu'un appel
  d'outil arrive, au lieu de la fin du flux. Sans ça il partait APRÈS
  l'événement d'outil et la fin de phrase — souvent coupée en plein mot —
  atterrissait dans une bulle séparée sous l'outil.
- Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle « appel
  identique répété » sont SUPPRIMÉS : ils coupaient des tours légitimes (une
  recherche enchaîne facilement des dizaines d'appels). Le bouton stop reste
  le seul frein. Endpoint /api/agent/tool-limit et son interrupteur retirés.
- Prompt système : « You are Jean » (majuscule) — le modèle recopiait la casse
  et se présentait en minuscule.
- UI : titres du menu tous en monospace capitales via une seule règle (la casse
  vient du CSS, plus du HTML).
- UI : le message envoyé s'affiche tout de suite en gris (« envoi… ») et
  s'éclaircit quand le serveur le confirme ; il ne disparaît plus pendant
  l'aller-retour, et il est retiré si l'envoi échoue.
- UI : voile de chargement du fil (« chargement de la conversation… » /
  « connexion au serveur… ») — un chat vide ne se confond plus avec une
  connexion lente.
- UI mobile : hauteur de coque 100dvh hors PWA (100vh en PWA), et plus aucun
  défilement du document. Le mode PWA est détecté par navigator.standalone :
  le média display-mode ne matche pas, faute de manifeste.
2026-07-29 18:49:53 +02:00
nathaninline a3a9c36367 v0.6.2 : carte de saisie correctement posee en bas sur iPhone (PWA) + listes deroulantes lisibles en sombre
Coque en 100vh au lieu de 100dvh : mesure faite sur iPhone en PWA, la vue web
couvre 956 pt mais la fenetre de mise en page n'en fait que 894, calee en haut.
100dvh (comme height:100%) vaut 894 -> il restait une bande morte de 62 pt sous
la carte de saisie, qui se recalait au premier defilement. 100vh se refere au
grand viewport et vaut bien 956.

La marge de zone sure passe du <body> a la carte elle-meme : geometrie
identique, mais l'arrondi bas reste detache du fond.

Listes deroulantes : la liste ouverte d'un <select> n'herite pas du fond de son
select ; nos selects transparents a texte clair donnaient du texte clair sur
fond blanc en theme sombre (signale sur Cache KV, en fait toutes les listes).
Les <option>/<optgroup> portent desormais les couleurs du theme.
2026-07-29 00:04:31 +02:00
nathaninline a7e3f0782c v0.6.1 : notifications de nouveau visibles + colonne de lecture alignee sur la saisie
- Les notifications du bas d'ecran (#toast) n'avaient pas de z-index. Depuis
  que la mise en page en colonne rend le composer absolu (z-index 4), elles
  etaient peintes DESSOUS et n'apparaissaient plus. Elles passent au-dessus du
  composer, sous les modales.
- La zone de chat reserve une gouttiere de barre de defilement de chaque cote
  (scrollbar-gutter: stable both-edges) ; le composer, lui, n'est pas defilant.
  Les messages etaient donc en retrait d'une gouttiere (~15 px) par rapport a
  la zone de saisie, ecart bien visible quand la barre laterale est escamotee.
  La gouttiere reelle est mesuree en JS et reportee sur le composer.
2026-07-28 15:48:48 +02:00
nathaninline 8fd71d6013 v0.6.0 : refonte de l'interface web (theme unique noir et blanc, menu lateral repense)
Interface
- Un seul theme, en deux variantes claire et sombre. Les themes Classique,
  Doux, ChatGPT et AJEAN sont retires, ainsi que le reglage de police et ses
  quatre variantes (plus aucun telechargement Google Fonts).
- La disposition en colonne centree devient celle de l'application : reponses
  sans bulle, question en pastille a droite, composer en pilule.
- Menu lateral refondu sur un gabarit unique repris des listes de la modale de
  preset : cartes, lignes de 34 px, intitule a gauche et controle a droite.
  Menu replie = 462 px, plus de defilement au chargement.
- GPU et RAM fusionnes en une section Machine, avec un vrai bloc de
  statistique (nom, valeur alignee, jauge, detail).
- Palette sombre plus contrastee : gris de texte a 7,6:1 (contre 5,6),
  bordures et surfaces enfin distinctes du fond.
- Le logo affiche toujours AJEAN (le A n'apparaissait qu'en mode agent) et
  passe en couleur d'accent quand l'agent est actif.
- Les garde-fous du serveur ([stop: ...]) s'affichent en encart discret.

Corrections
- Fond d'accent + texte blanc en dur : illisible en mode sombre (bouton
  enregistrer de l'editeur de preset, boutons accoles, bulle utilisateur).
- Pave des interrupteurs invisible sur piste claire, piste trop proche du fond.
- Valeurs cle/valeur inversees (« Go 24 / 18,2 ») : direction:rtl sans
  unicode-bidi:plaintext.
- Jauge de VRAM invisible (fond de la jauge = couleur de sa carte).
- Badges d'etat flottants dans les en-tetes (deux margin-left:auto).
- Ombres et voiles calibres pour un fond sombre, trop lourds sur du blanc.

Preferences
- /api/prefs n'accepte plus que theme=light|dark et les quatre drapeaux
  d'affichage ; les cles obsoletes (police, anciens themes) sont purgees a la
  premiere ecriture, et le client normalise un theme herite.
2026-07-28 15:31:11 +02:00
nathaninline 15bb9358bc v0.5.12 : la cle Crawl4AI quitte config.env pour un fichier dedie (0600)
Une cle en clair dans config.env finit dans les copier-coller de depannage.
Elle vit desormais dans $JEAN_HOME/.crawl4ai_key en 0600, comme la cle de
completion (.api_key) ; les installations qui l'avaient dans config.env sont
migrees a la premiere lecture, et l'ecriture efface toute trace residuelle.

Approche proposee par @feanor91 dans la PR #15 — merci a lui.
2026-07-28 11:43:40 +02:00
nathaninline 8956b1ed76 ci : gofmt sur web_prefs.go (alignement de la liste blanche des preferences) 2026-07-28 11:24:33 +02:00
nathaninline dbd4a6a570 v0.5.11 : themes clair/sombre par famille + police reglable + affichage a la carte + cle API Crawl4AI
- Apparence repensee : la liste ne montre plus que les FAMILLES de theme
  (Classique, AJEAN, Doux, ChatGPT) avec un interrupteur clair/sombre, au lieu
  d'une entree par variante. Nouveau theme « ChatGPT sombre » (fond quasi noir).
- Le theme par defaut redevient « clair ».
- Nouveau reglage « police », independant du theme : du theme / Mono / Systeme /
  Lexend / Serif (Lexend n'est telechargee que si elle est choisie).
- Le mode « complet / simple » laisse place a quatre interrupteurs : masquer le
  raisonnement, masquer les appels d'outils, garder les bulles repliees, barre
  laterale escamotable (tiroir meme sur ordinateur). L'ancien reglage est repris.
- Cle API du serveur Crawl4AI reglable depuis l'UI (et `jean internet key`) :
  envoyee en Authorization: Bearer sur toutes les requetes, jamais relue par
  l'API (indice sur 4 caracteres), preservee au changement de preset.
- Mise en page : colonne de lecture bornee a 900 px en mode escamotable, marges
  d'origine sinon ; tiroir de meme largeur que la barre fixe ; bouton ☰ qui ne
  recouvre plus le fil ; logo et numero de version recales.
2026-07-28 11:21:44 +02:00
nathaninline e2475c6688 v0.5.10 : le produit s'appelle AJEAN + diff des ecritures dans le chat + variante AJEAN sombre
- renommage : « Jean » devient « AJEAN » dans tous les textes visibles (titre de
  l'UI, proprietes du binaire Windows, barre de menus macOS / zone de
  notification, messages du terminal, README). La commande, les services, les
  chemins et le depot ne changent pas.
- nouveau thème « AJEAN sombre » (même habillage pixel, palette nuit) ; les
  regles de mise en page sont partagees via [data-theme^="ajean"].
- l'IA montre ce qu'elle ecrit : les outils edit / mem_add / mem_edit renvoient
  un diff ligne a ligne, affiche en vert (+) et rouge (-) dans la bulle d'outil
  et persiste avec la conversation (chat_diff.go).
- ecritures idempotentes : si la modification est deja en place, edit et
  mem_edit repondent « deja a jour » au lieu d'une erreur trompeuse ; un appel
  d'outil rigoureusement identique n'est plus rejoue dans le meme tour.
- saisie du chat auto-agrandissante (une ligne au repos en thème ChatGPT, deux
  ailleurs), bouton d'envoi enfin aligne, astuce clavier detachee de la carte.
- editeur de preset relu pour le thème AJEAN (plus de double encadrement), champ
  + bouton de telechargement recolles dans tous les thèmes, interrupteurs carres.
- emojis retires de l'interface (les glyphes monochromes restent).
2026-07-28 10:20:19 +02:00
nathaninline 6195d77759 v0.5.9 : nouveau theme AJEAN (bleu pixel, clair) par defaut + logo AJEAN dessine en pixels
- theme "AJEAN" : fond clair quadrille, bleu #1f6feb, aucun arrondi, bordures de
  2 px, ombres en blocs decales, boutons en capitales qui s'enfoncent au survol.
  Meme charte que la landing ajean.app.
- il devient le theme par defaut (registre, initTheme, script anti-flash du head).
- correctif : "ajean" manquait dans la liste blanche serveur (web_prefs.go), donc
  le choix etait rejete et le thème "sautait" a chaque rafraichissement.
- logo : le mot AJEAN est dessine en pixels (SVG, grille 4x5 par lettre, trait
  d'un module, j repris de la tuile du favicon) au lieu d'etre ecrit en texte ;
  le "a" du mode agent garde son animation d'apparition.
2026-07-28 07:58:06 +02:00
nathaninline 1b07054943 v0.5.8 : correctif reponse affichee en double (compaction de fin de tour) 2026-07-27 13:01:55 +02:00
nathaninline 36070387d0 correctif : reponse affichee en double quand la compaction de fin de tour fusionne un bloc que le client suivait en direct 2026-07-27 12:47:15 +02:00
nathaninline d22e8c8622 v0.5.7 : nouveau theme ChatGPT (noir et blanc, mise en page epuree) + composer en carte avec jauge de contexte integree 2026-07-27 12:22:56 +02:00
nathaninline cdcf89b068 ci : gofmt sur web_server.go (alignement des routes /api) 2026-07-27 10:44:35 +02:00
nathaninline 83a3a79abe v0.5.6 : backends custom depuis un depot Git + editeur de preset repense (champs facon Reglages iOS) + incompatibilite modele/moteur signalee 2026-07-27 10:35:04 +02:00
nathaninline 1af9262e97 v0.5.5 : conversation unique sur Mac/Windows (tunnel dans le process de l'app) 2026-07-25 14:00:19 +02:00
nathaninline 1118a15273 app de bureau : UN SEUL process proprietaire de la conversation
La conversation est un objet en memoire persiste dans conversation.json :
deux process qui la servent = deux fils divergents qui s'ecrasent. Sous
Linux le service jean-link sert deja les DEUX surfaces (UI locale :8090 +
tunnel) pour cette raison ; le worker detache ajoute en v0.5.4 rompait ce
principe sur Mac/Windows -> conversations differentes entre le local et
app.ajean.link.

L'app fait desormais tourner le tunnel dans son propre process, avec le
MEME mux que l'UI locale, et supprime au demarrage tout worker detache
rescape. Corollaire assume : l'acces distant vit tant que l'app est ouverte
(sur un portable qui s'endort, c'etait deja le cas en pratique).
2026-07-25 13:48:33 +02:00
nathaninline f8e856ed7c acces distant : remplacer un worker de tunnel issu d'une autre copie de l'app
Le worker est detache expres pour survivre a la fermeture de Jean.app : un
worker lance par une ancienne copie (translocatee ou version precedente)
continue donc de servir l'UI distante avec du code perime — l'alerte App
Translocation s'affichait sur app.ajean.link alors que l'app locale tournait
bien depuis /Applications.

Le fichier PID porte desormais le binaire d'origine en 2e ligne ; au
demarrage, un worker dont le binaire differe du notre est redemarre.
2026-07-25 13:29:33 +02:00
nathaninline 43ea4ad6c7 UI : plus de requetes suspendues indefiniment (saturation de connexions)
Chaque onglet Jean ouvert monopolise une connexion SSE permanente ; au-dela
du plafond navigateur (~6 par domaine) toute nouvelle requete part en file
d'attente sans jamais aboutir NI echouer : UI figee sur << chargement... >>
alors que le serveur repond en quelques ms (verifie en curl).

- un onglet cache relache son flux SSE et le reprend au retour (le replay
  depuis lastSeq rattrape ce qui a ete manque)
- timeout de 30 s sur les appels /api/* ordinaires, avec un message explicite
  au lieu d'une attente infinie
2026-07-25 12:53:29 +02:00
nathaninline e7d837d17e macOS : modele resolu depuis JEAN_HOME + alerte App Translocation
- config.env stocke MODEL sous forme de nom de fichier : sous systemd/launchd
  le WorkingDirectory vaut JEAN_HOME, mais une app de bureau demarre avec
  cwd=/ et llama-server ne trouvait pas le .gguf. cmdServe resout desormais
  MODEL et BIN relatifs contre JEAN_HOME, et le service en mode utilisateur
  fixe cmd.Dir (macOS + Windows).
- erreur explicite << modele introuvable : <chemin> >> au lieu d'un echec muet
- bandeau d'alerte quand l'app tourne depuis une copie AppTranslocation
  (app non signee ouverte depuis Telechargements) : chemin different a chaque
  lancement, instances concurrentes, maj du moteur impossible
2026-07-25 12:39:04 +02:00
nathaninline 9fe955d5ae backends precompiles : extraire les liens symboliques des archives
Les archives llama.cpp macOS/Linux livrent les bibliotheques sous leur nom
versionne (libllama-common.0.0.10107.dylib) PLUS un lien portant le nom
recherche par l'editeur de liens (libllama-common.0.dylib). L'extracteur
tar ignorait les entrees de type lien : backend installe mais llama-server
mort-ne sur << dyld: Library not loaded >>.

- extractArchive cree les liens (copie de repli quand os.Symlink echoue)
- marqueur de format dans VERSION : une install faite par l'ancien
  extracteur est refaite au lieu d'etre declaree << deja a jour >>
- test de non-regression + go test ajoute au job macOS de la CI
2026-07-25 12:29:47 +02:00
nathaninline 43da4b9197 macOS : DYLD_LIBRARY_PATH pour llama-server + journal du moteur dans l'UI
- dyld IGNORE LD_LIBRARY_PATH : sur macOS les .dylib livrees a cote de
  llama-server n'etaient pas trouvees, le moteur mourait sans un mot
- nouvelle route GET /api/service/log (journalctl sous Linux, fichier de
  log ailleurs) + panneau repliable ouvert en cliquant la pastille d'etat :
  on voit enfin POURQUOI un modele ne charge pas
2026-07-25 12:21:02 +02:00
nathaninline 075a05ebaa acces distant : le tunnel jean-link tourne aussi hors systemd (macOS/Windows)
linkServiceCtl/linkServiceActive etaient Linux-only : sur un Mac le token
etait bien enregistre mais rien ne composait jamais le tunnel, d'ou le
message << service arrete >> permanent. Hors Linux on lance desormais
'jean link serve' en processus detache suivi par un fichier PID
(spawnDetached/pidAlive/killTree, nouvelles primitives par plateforme).

- le mode app relance le tunnel au demarrage si une cle est enregistree
- nouvelle route POST /api/link/start + bouton << demarrer le tunnel >>
  dans le panneau, visible quand le lien est configure mais arrete
2026-07-25 12:02:31 +02:00
nathaninline 9787923879 macOS : service en mode utilisateur quand aucun LaunchDaemon n'est installe
Jean.app tourne sans root : launchctl load echouait sur un plist absent et
l'UI restait bloquee sur << service arrete >>. On bascule alors sur le meme
mecanisme que Windows (jean serve detache + fichier PID), avec setsid pour
survivre a la fermeture de l'app et un kill par groupe pour emporter
llama-server. Ajoute aussi l'action 'status', qui manquait cote darwin.
2026-07-25 11:37:42 +02:00
nathaninline a1b0cc0c58 macOS : JEAN_HOME utilisateur, RAM detectee, PATH Homebrew depuis le Finder
- /etc/jean n'est pas ecrivable par une app lancee depuis le Finder ->
  repli sur ~/Library/Application Support/jean (sauf sudo jean install)
- /api/ram lisait /proc/meminfo uniquement : ajout sysctl+vm_stat (macOS)
  et GlobalMemoryStatusEx (Windows, qui ne remontait rien non plus)
- PATH herite du Finder complete avec Homebrew/MacPorts : sans ca cmake,
  git et brew sont invisibles pour la compilation du backend
2026-07-25 11:30:02 +02:00
nathaninline 89cbb50fcf ci : workflow mac-build a la demande (Jean.app en artifact, sans release) 2026-07-25 11:22:31 +02:00
nathaninline 236bf4e978 ci : cibles macOS sur un runner Apple (CGO) au lieu du cross-compile 2026-07-25 10:27:18 +02:00
nathaninline ac171e5461 v0.5.4 : app macOS Jean.app (clic = UI web + icone barre de menus)
- Jean.app publie par la CI (zip par archi) : ouverture depuis le Finder ->
  mode application, comme le double-clic sur jean.exe sous Windows
- icone dans la barre de menus macOS (systray/Cocoa) avec Ouvrir/Quitter,
  bundle marque LSUIElement (pas d'icone Dock)
- builds darwin deplacees sur un runner macOS (CGO requis pour Cocoa)
- signature ad-hoc du bundle + icone .icns
2026-07-25 10:23:33 +02:00
nathaninline 2f1f40c604 v0.5.3 : détection du preset actif robuste + retouches UI
- Presets : la détection du preset actif compare désormais l'ENSEMBLE des
  KEY=VALUE effectifs (ordre, commentaires, lignes vides et clés appareil
  ignorés) au lieu d'un hash ligne à ligne. Fini le preset qui n'apparaît plus
  en surbrillance après un reformatage de config.env (toggle mémoire/internet…).
  Couvert par un test unitaire.
- UI : toggles « Paramètres » du mode agent dans des cartes bordées ; champs de
  la modale MCP correctement espacés ; section MOTEUR sans contours colorés ;
  liens « mettre à jour » / « vérifier la version » sans soulignement.
2026-07-24 17:29:50 +02:00
nathaninline 142dd87299 v0.5.2 : robustesse appels d'outils + nettoyage UI
- Chat : valide le JSON des arguments d'outil avant de les stocker dans
  l'historique. Un modèle très quantifié pouvait émettre des arguments
  tronqués/non-JSON (non vides) qui, persistés, faisaient échouer le template
  de chat de llama.cpp à chaque requête → 500 en boucle jusqu'au reset.
  json.Valid remplace le simple test != "" (llm_client.go).
- UI : section « Accès OpenAI » remise au propre (adresses locale/publique
  groupées, clé API), sans émojis ; « Accès distant » sans émojis. Classes
  .io-input/.io-cap partagées.
2026-07-24 17:06:13 +02:00
nathaninline d8b08dbb89 v0.5.1 : accès distant ajean.link depuis l'UI + app Windows sans console
- Panneau « Accès distant » dans l'UI web : abonnement + liaison ajean.link
  en un clic (popup connect.html → clé remise à l'agent local), sans terminal.
  Endpoints locaux /api/link/status|connect|disconnect|paircode (web_link_api.go).
- Accès OpenAI public : refuse d'être activé sans accès distant, avec message.
- Windows : binaire compilé en sous-système GUI (-H=windowsgui) → plus aucune
  fenêtre de console au double-clic ; rattachement à la console parente en CLI
  (sys_console_windows.go). Suppression du hack relaunchDetachedApp.
- Windows : masquage (CREATE_NO_WINDOW) de TOUS les sous-process — git/vswhere
  du panneau MOTEUR (flash à chaque refresh), llama-server, MCP stdio, winget…
- Vouvoiement du nouveau parcours.
2026-07-24 16:39:09 +02:00
nathaninline 8e57685b19 v0.5.0 : intégration MCP (Model Context Protocol)
Jean peut désormais se connecter à des serveurs MCP tiers pour enrichir la
palette d'outils de l'IA (accès fichiers, bases de données, APIs métier…).

- Transports stdio (process local npx/uvx/binaire) et http (Streamable HTTP
  distant, en-têtes d'auth possibles), via le SDK officiel go-sdk.
- Outils namespacés mcp__<serveur>__<outil>, exposés seulement en mode agent
  (même niveau de confiance que le shell : exécution de code sur l'hôte).
- Sessions persistantes (connexion paresseuse + cachée, reconnexion auto).
- Gestion complète dans l'UI web : ajout/édition de serveurs, activation par
  serveur ET par outil (pour maîtriser le contexte), état de connexion en direct.
- Config dans mcp.json (format Claude Desktop mcpServers, copiable).
2026-07-24 12:49:56 +02:00
nathaninline e8f566ebc0 v0.4.16 : historique de chat - fini la perte du debut
Le journal d'affichage stockait un evenement par token (plafond 5000) -> il se
remplissait en quelques reponses et on ne pouvait plus remonter jusqu'au debut.
Desormais il est coalesce en fin de tour (un tour = une poignee d'evenements au
lieu d'un par token), plafond monte a 20000. Des centaines de tours tiennent
maintenant dans l'historique.
2026-07-23 20:03:46 +02:00
nathaninline 7cfc548474 Historique chat : coalescer le journal en fin de tour (fini la perte du debut)
Le journal d'affichage stockait un evenement par TOKEN et etait plafonne a 5000
-> il se remplissait en quelques reponses et perdait le debut de la conversation
a l'ecran (impossible de remonter jusqu'au deput). Fix : compactLogLocked()
coalesce les suites content/reasoning en un seul evenement chacun a la fin de
chaque tour (le replay le faisait deja, pas le stockage), en preservant toks/ts0
pour le compteur tok/s. Plafond monte a 20000 pour absorber un gros tour en cours
avant sa coalescence. Un tour terminé pese desormais une poignee d'evenements au
lieu d'un par token -> des centaines de tours tiennent dans le journal.
2026-07-23 20:01:54 +02:00
nathaninline ac8095024f v0.4.15 : compaction du contexte revue de fond en comble
- Progression visible : banniere "compactage en cours" pendant le resume (au lieu
  d'une UI figee), jauge de contexte qui chute immediatement.
- Bouton "compacter" fonctionnel : declenche une vraie compaction manuelle
  (visible des 50%), au lieu d'un toast inutile.
- Compaction efficace : budget de queue base sur la conversation (pas la fenetre)
  -> corrige le "rien a compacter" ; resume borne et dense (max_tokens, thinking
  desactive, plafond dur) ; reduction garantie >= 20%.
- Prompt resumeur universel : en anglais, resume dans la LANGUE de la conversation,
  avec objectif explicite (continuer sans perdre d'info importante).
- Erreurs moteur claires : "connection refused"/timeout/EOF traduits en message
  comprehensible au lieu du brut "dial tcp ...".
2026-07-23 17:56:38 +02:00
nathaninline 4223606780 Compaction : prompt resumeur en anglais + resume dans la langue de la conversation
- Prompt resumeur, marqueur injecte, labels de transcription et marqueur de
  troncature passes du francais code en dur a l'anglais (coherent avec le system
  prompt principal, app universelle) ; le resume est produit dans la LANGUE de la
  conversation.
- Ajout de l'objectif explicite du resume : continuer la conversation sans perdre
  d'info utile/importante pour comprendre le contexte.
2026-07-23 17:46:55 +02:00
nathaninline 2c1c9bc3f0 CI: gofmt sur web_server.go (alignement des routes casse la CI) 2026-07-23 17:32:45 +02:00
nathaninline ea5d0eed4f Compaction : budget de queue base sur la conversation, pas la fenetre (fix "rien a compacter")
Bug : tailBudget = 0.25 x fenetre. Une conversation de 25k tokens dans une
fenetre de 64k gardait 16k en queue -> torse minuscule (~4k) -> reduction < 20%
-> refusee -> "rien a compacter" a 68%. Corrige : tailBudget = 0.25 x taille
estimee de la conversation -> on garde ~25% des tours recents et on compacte les
~75% du debut, quelle que soit la fenetre.

Aussi : la jauge post-compaction ajoute le surcout fixe non compactable (prompt
systeme injecte + schemas d'outils = contexte reel - estimation messages), pour
afficher une valeur realiste au lieu de chuter trop bas puis resauter.
2026-07-23 17:28:12 +02:00
nathaninline dcbfcb36f9 Compaction plus efficace : resume borne, reduction garantie, jauge immediate
- Resume borne et dense : max_tokens 700, consigne 250 mots max, plafond dur a
  1500 caracteres, et surtout thinking DESACTIVE pour l'appel de resume
  (chat_template_kwargs enable_thinking:false) -> plus rapide, plus dense, plus
  de budget gaspille en <think> par un modele hybride.
- Garantie de reduction : la compaction n'est acceptee que si elle enleve >=20%
  du contexte estime, sinon refusee -> fini le "compactage a chaque message sans
  reduire". Queue plus agressive (tailFrac 0.35 -> 0.25) : le contexte retombe
  plus bas et met plus longtemps a re-declencher.
- Jauge de contexte : chute immediatement apres compaction (delta ctx_used emis
  aussitot) au lieu d'attendre la fin du tour.
2026-07-23 17:07:55 +02:00
nathaninline 7720877533 Compaction : progression visible, bouton fonctionnel, erreurs moteur claires
- Banniere "Compactage du contexte en cours" pendant le resume (emis avant/apres
  via deltas compacting), au lieu d'une UI figee sans info.
- Le bouton "compacter" declenche desormais une VRAIE compaction manuelle
  (/api/chat/compact -> CompactNow, detache) ; visible des 50% de contexte. Fini
  le toast "c'est automatique" qui ne faisait rien.
- Erreurs de transport llama-server (connection refused / timeout / EOF)
  traduites en message clair ("le moteur demarre/charge, reessaie") au lieu du
  brut "dial tcp 127.0.0.1:8080: connect: connection refused".
2026-07-23 16:51:00 +02:00
nathaninline 0a81d8e4f4 v0.4.14 : refonte du modale editeur de preset
- Modale d'edition de preset refait au propre : classes CSS reutilisables,
  bloc "Modele & moteur" regroupe en carte, selecteur de moteur en 3 segments.
- Section telechargement : "Telecharger un modele" (pas forcement Hugging Face)
  + bouton reduit a une icone compacte collee au champ.
2026-07-23 15:48:52 +02:00
nathaninline ec4de88951 UI editeur preset : "Telecharger un modele" + bouton telechargement compact
- Libelle "Telecharger depuis Hugging Face" -> "Telecharger un modele"
  (pas forcement HF, juste un lien direct .gguf).
- Le bouton devient une icone compacte (⬇) collee au champ (input-btn) au lieu
  d'un gros bouton "telecharger" qui prenait toute la largeur.
2026-07-23 15:41:56 +02:00
nathaninline 37c4b4ca52 UI: refonte propre du modale editeur de preset
Styles inline -> classes CSS reutilisables (.modal-*, .fld, .ctl, .card, .seg).
Bloc modele/moteur regroupe dans une carte "Modele & moteur", selecteur de
moteur en 3 segments (rapide/optimise/perso), hierarchie et espacements clairs.
Verifie desktop + mobile. IDs et handlers inchanges.
2026-07-23 15:35:14 +02:00
nathaninline 3f03808e2c v0.4.13 : RAM dans la sidebar, config active clarifiee, verification de moteur
- Sidebar : nouvel affichage de la RAM systeme (used/total, /proc/meminfo).
- Config active : BIN devient MOTEUR (rapide/optimise/custom+chemin), ajout de
  N-CPU-MOE si present dans EXTRA_ARGS, retrait de PORT (deja affiche en haut).
- Section moteurs : renommee MOTEUR, passage au vouvoiement, et lien
  "verifier la version" (interroge la derniere version sans installer).
2026-07-23 15:08:59 +02:00
nathaninline 2b5356acd8 UI config active : N-CPU-MOE en majuscule 2026-07-23 13:59:26 +02:00
nathaninline 3574039d56 UI config active : retire PORT (deja affiche en haut) 2026-07-23 13:57:35 +02:00
nathaninline 6d54c45afa UI moteur : section renommee MOTEUR, vouvoiement, verification de version
- Titre "Backend llama.cpp" -> "MOTEUR".
- Passage au vouvoiement dans tous les textes de la section.
- Nouveau lien "verifier la version" sur chaque moteur (rapide/optimise) :
  interroge la derniere version dispo (endpoints check existants) SANS installer,
  et affiche le resultat en toast avant tout lancement d'install.
2026-07-23 13:56:09 +02:00
nathaninline 996f918e72 UI: MOTEUR en majuscule dans la config active 2026-07-23 13:49:53 +02:00
nathaninline a04613a87d UI: 'BIN' renomme 'moteur' dans la config active 2026-07-23 13:47:39 +02:00
nathaninline 6bdf9e8ae4 UI config active : BIN affiche le moteur (rapide/optimise/custom+chemin) + n-cpu-moe
- BIN devient un libelle lisible : "rapide" (moteur precompile), "optimise"
  (compile), ou "custom : <chemin>" sinon (via in_use / prebuilt.in_use de
  /api/llamacpp). Le chemin complet reste en tooltip.
- Ajout d'une ligne "n-cpu-moe" quand --n-cpu-moe est present dans EXTRA_ARGS.
2026-07-23 13:45:20 +02:00
nathaninline dbb0ac40a4 UI: affichage de la RAM systeme dans la barre laterale
Nouvel endpoint /api/ram (lecture /proc/meminfo, used/total en Mo, 0 hors Linux)
et bloc RAM dans la sidebar a cote du GPU, rafraichi toutes les 3 s. Masque si
pas de /proc/meminfo (Windows/mac).
2026-07-23 13:40:53 +02:00
nathaninline ee76638f15 v0.4.12 : fix reasoning Laguna S 2.1 (reponse qui partait dans le thinking)
Les modeles hybrides (Laguna S 2.1) qui repondent SANS reflechir n envoyaient
que `content` (ni reasoning_content, ni </think>, retire par le backend a
--reasoning-format). jean, avec REASONING=on, supposait un bloc <think> ouvert
par le prompt et vidait toute la reponse dans la bulle thinking. On streame
desormais le contenu en direct par defaut et on ne bascule en reasoning qu au
vu d un </think> reel. La coupe de la queue anti-</think> est realignee sur une
frontiere de rune UTF-8 pour ne plus casser les accents (e -> mojibake).
2026-07-23 11:49:40 +02:00
nathaninline ef5b036d99 backend llama.cpp : choix du moteur PAR MODELE (3 options), la barre laterale n'installe plus que
Corrige la confusion : deux endroits ecrivaient la ligne BIN et se battaient
(la barre laterale changeait le BIN global + redemarrait, puis selectionner un
preset remettait le BIN du preset). Desormais :
- la section laterale 'Backend llama.cpp' sert UNIQUEMENT a installer / mettre
  a jour les moteurs (rapide / optimisee), elle ne change plus le BIN actif
- le choix du moteur se fait PAR MODELE, dans l'editeur de preset, avec 3
  options claires : Rapide (precompile) / Optimise (compile) / Personnalise
  (chemin libre ou backend depose dans le dossier backends de jean, detecte)
- detection auto de l'option a l'ouverture selon le BIN du preset ; l'astuce
  indique le dossier ou deposer un binaire custom
- /api/llamacpp expose backends_dir ; l'endpoint /use n'est plus utilise par l'UI

v0.4.11
2026-07-22 20:17:23 +02:00
nathaninline c5522a51a5 v0.4.10 : backend llama.cpp, interface simplifiee (2 modes rapide / optimisee) 2026-07-22 19:55:04 +02:00
nathaninline ff045ba21e UI backend llama.cpp : vue simplifiee (2 modes rapide/optimisee, plus de badge, sans jargon)
Refonte de la section pour la rendre utilisable sans connaissances techniques :
- suppression du badge a cote du titre et de tous les details (commit, retard
  git, arch CUDA, nombre de jobs, chemins) ; une seule ligne 'Moteur actif : …'
- deux cartes claires : Version rapide (precompile, conseillee) / Version
  optimisee (compilee) ; un clic installe, active ou bascule
- bascule instantanee entre deux versions deja installees (nouvel endpoint
  /api/llamacpp/use : repointe BIN + redemarre, sans recompiler)
- carte active : '✓ Actif' + 'mettre a jour' ; progression et logs replies sous 'details'
2026-07-22 19:54:48 +02:00
nathaninline b0c1503ed8 v0.4.9 : backend llama.cpp precompile (binaires officiels, plus besoin de recompiler) 2026-07-22 19:22:34 +02:00
nathaninline b1890ed6f7 backend llama.cpp : mode binaires precompiles officiels (aucune compilation)
Alternative rapide a la compilation locale : telechargement des binaires
officiels de ggml-org/llama.cpp publies a chaque release (~2 min au lieu de
~40 de build CUDA). Le variant est choisi selon la machine (CUDA Windows avec
selection de la version selon le pilote + cudart, Vulkan, Metal, ROCm, CPU),
extrait dans backends/llama.cpp-prebuilt et BIN repointe dessus.

- backend_prebuilt.go : detection release/variant, telechargement avec
  progression, extraction zip/tar.gz anti zip-slip, marqueur VERSION
- UI : bouton 'binaires precompiles' + 'compiler (sources)', le statut
  distingue le mode actif (precompile vs build local), 'verifier les maj'
  compare a la derniere release officielle en mode precompile
- CLI : 'jean llamacpp prebuilt'
- le build local reste sur le disque : bascule possible via l'editeur de preset

Limites : builds generiques (pas de tuning natif) ; pas de build CUDA officiel
Linux (variant Vulkan) ; la compilation locale reste requise pour les forks.
2026-07-22 19:22:08 +02:00
nathaninline 58b59da823 v0.4.8 : gestion du backend llama.cpp depuis l'interface web (statut, verification des maj, install / update / recompilation avec logs en direct) 2026-07-22 17:59:12 +02:00
nathaninline 7ac571974f gestion du backend llama.cpp depuis l'UI web : statut (commit, retard, binaire, accelerateur detecte), verification des maj (git fetch), install / update / recompilation en job asynchrone avec phases, compteur de fichiers compiles et logs de build streames (poll /api/llamacpp/job) ; le service est arrete pendant le build puis relance, BIN repointe automatiquement 2026-07-22 13:34:56 +02:00
nathaninline 29f249be09 release CI : generer SHA256SUMS dans le workflow (jean update le verifie depuis v0.4.6) - evite le desaccord binaires CI / sommes manuelles qui a casse jean update sur v0.4.7 2026-07-22 12:37:57 +02:00
nathaninline 6bfa554edf v0.4.7 : detection et reparation auto de l'integration Visual Studio de CUDA (fix 'No CUDA toolset found' au build llama.cpp Windows, issue #10) 2026-07-22 12:26:03 +02:00
nathaninline 4fea700edc build CUDA Windows : detection + reparation de l'integration Visual Studio de CUDA manquante (erreur cryptique 'No CUDA toolset found', issue #10) - verif avant cmake via vswhere, copie auto des MSBuildExtensions du toolkit si possible, sinon message clair avec les 2 solutions ; filet dans hintMissingBuildDep 2026-07-22 12:24:33 +02:00
nathaninline 48c3e807be v0.4.6 : correctifs de la revue de code (reset/epoch, persistance du message user, cle jamais en URL, SHA256SUMS verifie par jean update, permissions preservees par edit, port conflict Windows) 2026-07-22 11:59:54 +02:00
nathaninline 02d164899e fix revue de code : reset pendant generation (epoch guard, plus de messages fantomes), message user persiste des l'envoi, cle de pilotage refusee en query string (fuite logs/historique), verif SHA256SUMS dans jean update, edit preserve les permissions du fichier, conflit de port gere sous Windows (netstat/taskkill), /api/update/apply renvoie 500 en echec 2026-07-22 11:57:56 +02:00
nathaninline cd570c5c5e fix theme doux sombre : accepter 'soft-dark' dans la whitelist /api/prefs (le theme sautait au refresh, ecrase par la pref serveur) 2026-07-22 11:32:37 +02:00
nathaninline 29efc82420 v0.4.5 : theme doux sombre, fix prompt systeme (persiste cote serveur), rangement complet du projet (cmd/+internal/, ui/src/, CI, tests e2eauth) 2026-07-22 11:26:39 +02:00
nathaninline 274dfbb516 UI: nouveau theme 'doux sombre' (soft-dark) - Lexend + palette chaude sur fond sombre, demande utilisateurs 2026-07-22 11:23:00 +02:00
nathaninline 1d2dba6501 fix: le prompt systeme de l'UI marchait dans le vide depuis la conversation serveur (v0.4.x) - il est desormais persiste cote serveur (/api/sysprompt, JEAN_HOME/sysprompt.txt, partage entre appareils) et injecte a chaque tour de generation (fusionne avec le preambule agent par InjectSkills) 2026-07-22 11:17:07 +02:00
nathaninline d5e0606dcc UI: assemblage via tools/assemble-ui (Go, multiplateforme) branche sur go generate - remplace ui/assemble.ps1 2026-07-22 11:06:19 +02:00
nathaninline b4b30cd47a Go: decoupe des 3 gros fichiers - web_server (mux/cmd) + web_api (handlers REST) + web_chat (SSE), backend_llamacpp (commandes) + backend_build (machinerie cmake), chat_internet (client crawl) + chat_internet_tools (outils du modele) ; deplacement pur, zero changement de code 2026-07-22 11:05:02 +02:00
nathaninline 76f0d963f9 CI GitHub Actions (build+vet+gofmt+tests+cross-compile sur push ; release 6 cibles auto sur tag v*) + tests relay_e2eauth (anti-rejeu, lockout appairage, normalisation code) 2026-07-22 11:03:28 +02:00
nathaninline 725a8161ce UI: retrait du doublon openBenchModal/closeBenchModal (07-models.js) 2026-07-22 11:02:14 +02:00
nathaninline 392f85ec3b gofmt -w sur les 6 fichiers non conformes (alignements de champs/maps uniquement) 2026-07-22 11:01:55 +02:00
nathaninline 71e7a5c369 gitattributes: .go en LF (gofmt -l ne crie plus a cause d'autocrlf) 2026-07-22 11:00:55 +02:00
nathaninline e3fe63ca24 Go: layout standard cmd/ + internal/ - tout le code dans internal/jean (package jean), cmd/jean = main() + ressources Windows (.syso/icone/versioninfo), ui/ demenage dans internal/jean/ui (go:embed inchange) ; racine GitHub epuree 2026-07-22 10:53:14 +02:00
nathaninline 79e8d322a9 Go: rangement des 45 fichiers par domaine (cli_/web_/chat_/llm_/backend_/relay_/sys_) + doc.go carte du code - renommages purs (git mv) + maj des noms cites en commentaires, zero changement de code 2026-07-22 10:47:42 +02:00
nathaninline 7af70c3d2e README: documenter ui/src/ + assemble.ps1 (index.html genere) 2026-07-22 10:27:49 +02:00
nathaninline c1b006e9ac UI: en-tete 'fichier genere' dans index.html (via template) 2026-07-22 10:24:24 +02:00
nathaninline 8a2450dff6 assemble.ps1: marqueurs tolerants LF/CRLF (autocrlf) + remplacement via MatchEvaluator (contenu JS avec \$ intact) 2026-07-22 10:23:52 +02:00
nathaninline 0770723ef0 UI: decoupe index.html en sources ui/src/ (styles.css + js/NN-*.js + template) assemblees par ui/assemble.ps1 - index.html reste genere/committe (go:embed et build-server-ui.ps1 inchanges), byte-identique 2026-07-22 10:22:54 +02:00
nathaninline 5f256befea UI: icone MAJ monochrome (fleche noire) au lieu de l'emoji colore 2026-07-22 09:54:41 +02:00
nathaninline c611893bcd UI: bouton mise a jour en haut des actions + icone dediee, retrait de la ligne 'Version installee' (deja affichee en haut) 2026-07-22 09:52:00 +02:00
nathaninline e0e5118063 v0.4.4 : le bouton mise a jour redemarre automatiquement jean-link
Apres une MAJ depuis l'UI, plus besoin de SSH sur le serveur pour finir : sur
Linux/systemd le service jean-link est relance tout seul (differe + --no-block,
la reponse HTTP part d'abord ; jean.service/llama-server n'est PAS touche pour
ne pas recharger le modele). La cle E2E survit au restart, l'UI se reconnecte
seule. Poste client/Windows : comportement inchange (message d'indice).
2026-07-22 09:44:55 +02:00
nathaninline 6cb678d42d replay: coalescer aussi les tool_used (garder l'etat final done, jeter le streaming d'args) - divise par ~100 les events d'outils rejoues, fix lenteur mobile au refresh 2026-07-22 09:35:09 +02:00
nathaninline faa377e64f SSE: padding APRES caught_up (16Ko) au lieu de dessus - le dernier message n'est plus la queue coincee dans le tampon proxy 2026-07-22 09:19:39 +02:00
nathaninline 51c2c2d80f v0.4.3 : anti-buffering SSE (Cloudflare) - fin des derniers messages en retard au refresh
Le chat E2E d'app.ajean.link traverse Cloudflare (ajean.link, proxy orange) qui
bufferise le flux SSE par lots : la queue du replay arrivait 20-30s apres le reste,
de facon intermittente (surtout Safari iOS). Corrige par la technique standard :
padding d'amorcage en tete de flux + padding sur caught_up (force le flush de la
queue), Cache-Control: no-transform (interdit a Cloudflare de bufferiser/compresser),
et heartbeat 15s->4s (borne le pire cas).
2026-07-22 09:15:15 +02:00
nathaninline 7d104e3213 v0.4.2 : refresh instantane du chat (fin du layout thrashing O(n2) au replay)
Au refresh, le client rejouait tout le journal depuis seq 0 et forcait un reflow
synchrone par evenement (scrollMaybe lisait scrollHeight ; collapseInstant lisait
offsetHeight) sur un DOM grandissant -> cout quadratique, 20-30s pour que les
derniers messages s'affichent. Le scroll est desormais fait une seule fois a la
fin du replay (caught_up), plus aucun reflow force pendant le rejeu -> O(n).
2026-07-22 08:55:07 +02:00
nathaninline e16bebda8a v0.4.1 : correctif du bouton stop (regression v0.4.0 : stopGen supprimee par erreur) 2026-07-21 22:16:04 +02:00
nathaninline d55c0d4992 fix: bouton stop cassé — la fonction stopGen avait ete supprimee par erreur lors de la refonte de send() 2026-07-21 22:14:53 +02:00
nathaninline 696627246d v0.4.0 : conversation persistee cote serveur (historique partage multi-appareils, generation autonome) + compaction du contexte facon Hermes
- Compaction automatique du contexte facon Hermes (head/torso/tail, elagage des
  vieux tool outputs + resume du torse par le modele local) : discussions quasi
  illimitees sans 'vider', declenchee sur le vrai decompte de tokens
- Historique et generation deplaces CToe SERVEUR : une seule conversation partagee
  entre tous les appareils, persistee sur disque, generation autonome (fermer le
  navigateur n'arrete plus la reponse), replay complet au refresh (outils/vitesses)
- Un seul process (jean-link) sert le tunnel ET le :8090 local -> meme historique
  entre jean.n27.fr et app.ajean.link
- Relais toujours 100% boite noire (chiffrement E2E inchange)
- Nombreux polish UI (replay coalesce rapide, pas de clignotement au refresh, envoi
  resilient, reorganisation des reglages)
2026-07-21 21:37:35 +02:00
nathaninline 02d4830a17 UI: aide clavier 'Entree pour envoyer' de retour sur desktop, cachee seulement <720px (menu replie) 2026-07-21 21:34:31 +02:00
nathaninline f9165fb204 UI: bouton send/stop ne flotte plus au refresh (etat applique une fois au caught_up) 2026-07-21 21:29:18 +02:00
nathaninline 7192931ddc UI: fin du clignotement au refresh (chat masque pendant le replay, revele en bas au caught_up) 2026-07-21 21:25:30 +02:00
nathaninline a898f32e1a historique partage entre jean web (local) et jean link (tunnel)
Le process jean-link sert desormais AUSSI l'UI locale sur :8090 avec le MEME mux
que le tunnel -> une seule conversation en memoire, partagee entre jean.n27.fr et
app.ajean.link. Fini les deux process avec des historiques divergents qui
s'ecrasaient sur le meme fichier. Le 'jean web' autonome n'est plus necessaire.
2026-07-21 21:17:21 +02:00
nathaninline bddb5a1888 UI: fusion 'Mise a jour' dans 'Actions' + suppression du hint 'Entree pour envoyer' (gain de place, surtout mobile) 2026-07-21 21:12:35 +02:00
nathaninline 2f56d99d04 perf + robustesse: replay coalesce + envoi resilient
- Subscribe rejoue le journal COALESCE (deltas texte consecutifs fusionnes en un
  evenement, avec toks + bornes ts) : chargement rapide meme sur longue conv, fini
  le rendu markdown quadratique token par token
- send() resilient : retry, 409/generating traite comme succes -> plus de 'network
  error' alarmiste alors que l'IA repond quand meme ; reconnexion du flux plus rapide
2026-07-21 20:40:08 +02:00
nathaninline e28bc45557 UI: pas d'animation de repli au refresh + reorg reglages
- serveur emet {caught_up} quand le replay est draine ; le client cree alors les
  bulles raisonnement/outil DEJA repliees (collapseInstant, sans animation) tant
  qu'on rejoue le journal -> au refresh elles apparaissent fermees direct
- 'Mise a jour' passe au-dessus de 'Actions'
- le toggle compactage rejoint la section Parametres (sous le mode agent)
2026-07-21 20:28:10 +02:00
nathaninline 54e14c7935 vitesse des bulles calculee depuis les horodatages serveur (ts par evenement)
Le calcul cote client (performance.now) donnait une vitesse presente ou non selon
que le tour etait live ou rejoue. On horodate chaque LogEvent cote serveur (TS) et
le client derive tok/s de firstTs->lastTs : correct en direct ET au replay. Hack
replayTarget supprime.
2026-07-21 17:38:28 +02:00
nathaninline 3748d3a25f UI: restaure le comptage tokens + vitesse en direct sur les bulles (raisonnement et reponse)
La refonte serveur avait supprime le compteur live par bulle. On le recalcule
cote client au fil des deltas ; en replay (refresh) on n'affiche que le nombre de
tokens (pas de vitesse absurde car tout arrive d'un bloc), via replayTarget.
2026-07-21 17:29:02 +02:00
nathaninline 4514a96585 conversation persistee cote serveur + generation autonome (multi-appareils)
L'historique et la generation vivent desormais sur le serveur jean, plus dans le
localStorage du navigateur :
- conversation.go : store unique (Messages vue modele + Log d'evenements rejouable),
  persistance disque JeanHome/conversation.json, generation dans une goroutine
  detachee (context.Background) -> fermer le navigateur ne coupe plus la reponse
- runChatStream devient un pur abonne (rejoue Log[from:] puis suit le direct) ;
  nouveaux endpoints /api/chat/send|stop|reset|state
- UI : flux d'abonnement permanent + replay du journal (outils/vitesses/raisonnement
  reviennent apres refresh), synchro live inter-appareils, reconnexion auto
- compactage manuel neutralise (auto cote serveur), vitesses rendues depuis les
  stats persistees
2026-07-21 17:20:27 +02:00
nathaninline 2daea32ade compaction: decider sur le VRAI decompte de tokens (ctx_used du client) au lieu de l'estimation
L'heuristique ~4 char/token sous-estimait le contexte (elle ne voit ni le prompt
systeme injecte ni le gabarit de chat), donc le seuil 75% n'etait jamais franchi.
Le client envoie desormais ctx_used (prompt_tokens_total deja affiche) ; repli sur
l'estimation quand inconnu (terminal).
2026-07-21 16:18:50 +02:00
nathaninline e6c0bbe3b7 compaction du contexte facon Hermes : conversations quasi illimitees sans clear
- nouveau compact.go : split head/torso/tail, elagage des vieux tool outputs
  sans IA + resume du torse par le modele local (un appel non-streame)
- proactif au seuil (75% de CTX) dans runChatStream + chat terminal, et filet
  reactif dans runChat si llama-server refuse un prompt trop long
- resume injecte en tour user->assistant (jamais un system au milieu : garde Qwen)
- toggle COMPACT (defaut on) : endpoint /api/agent/compact + case UI, event
  history_replace pour que le client remplace son historique, toast
- tests unitaires des frontieres head/tail et de la non-separation des paires outil
2026-07-21 15:49:18 +02:00
nathaninline c50ebdb6d1 v0.3.4 : gestion des mises à jour depuis l'interface web (section « Mise à jour » : vérifier + installer, endpoints /api/update et /api/update/apply) 2026-07-20 17:38:24 +02:00
nathaninline cd58382d73 v0.3.3 : mode application Windows (double-clic → UI + icône de notification + splash, icône sur l'exe), catalogue de modèles servi par ajean.link (/api/catalog + détection RAM), correctif des fenêtres de console qui clignotaient sous Windows, version affichée dans l'UI 2026-07-20 17:20:50 +02:00
nathaninline 0c4ff12a00 cleanup: supprime le code mort des skills (fondus dans la memoire)
- supprime skills.go (Skill, ListSkills, SkillContent, SaveSkill, AppendSkill,
  DeleteSkill, safeSkillDir, skillsSystemPrompt : plus aucun appelant)
- retire les handlers/routes /api/skill, /api/skill/save, /api/skill/delete
  (aucun client : ni UI locale, ni ajean-app, ni jean-relay)
- conserve les alias retro-compat /api/skills(/toggle) -> handleAgent, la
  migration migrateSkillsToMemory et legacySkillsFlag
2026-07-16 13:55:11 +02:00
nathaninline e08020844d doc: 'skills' remplaces par 'memoire' dans l'aide/README (les skills ont ete fondus dans la memoire) 2026-07-16 13:50:06 +02:00
nathaninline a762244e89 readme: alignement avec l'app actuelle (endpoint OpenAI <machine>.oai.ajean.link + toggle UI, REASONING_BUDGET=-1, TOOL_LIMIT, Go 1.25, apparence synchro) 2026-07-16 13:40:20 +02:00
nathaninline 5a3c6834ec readme: documente la commande 'jean update' 2026-07-16 13:31:11 +02:00
nathaninline f58299632b v0.3.2 : commande 'jean update' (auto-mise a jour depuis les releases GitHub) 2026-07-16 13:25:07 +02:00
nathaninline 342cdd0690 v0.3.1 : synchro des reglages d'apparence (theme/affichage) entre appareils via /api/prefs 2026-07-16 11:12:28 +02:00
nathaninline 7a795087b9 v0.3.0 : acces OpenAI public par machine + gestion cle depuis le web
- panneau Acces OpenAI dans l'UI (endpoint /v1, cle API masquee, generer/definir/retirer)
- endpoint OpenAI PUBLIC <machine>.oai.ajean.link (passthrough SNI, VPS relais aveugle,
  cert Let's Encrypt via TLS-ALPN-01 par le tunnel), toggle 'exposer en public'
2026-07-13 14:35:37 +02:00
nathaninline 4d9dcb2d6e oai: toggle UI exposer en public (drapeau .oai_public lu en direct au demux, sans redemarrage) + endpoint /api/oai/public 2026-07-13 14:30:52 +02:00
nathaninline 6767a816a9 oai: affiche l'URL publique <machine>.oai.ajean.link dans le panneau Acces OpenAI quand l'acces public est active 2026-07-13 14:25:49 +02:00
nathaninline b929b559c4 oai: ajoute http/1.1 aux protocoles ALPN (certmagic ne met que acme-tls/1, sinon les clients normaux sont rejetes) 2026-07-13 14:14:45 +02:00
nathaninline 4ecc4159de oai: cert TLS-ALPN-01 par le tunnel (on-demand, sans DNS) + demux agent (flux TLS brut vs HTTP au 1er octet) 2026-07-13 13:54:19 +02:00
nathaninline daefa8932c oai: cert Let's Encrypt via DNS-01 Cloudflare (phase 2) — certmagic + libdns/cloudflare, cle privee locale (/certs); jean oai cert <domaine> 2026-07-13 13:23:10 +02:00
nathaninline 28b6e33b2c oai: front TLS cote agent (phase 1) — termine le TLS localement + proxy vers llama /v1 (jean oai serve, cert auto-signe pour test) 2026-07-13 13:18:48 +02:00
nathaninline b818e253c2 web: endpoint OpenAI = adresse LAN detectee cote serveur (jamais le domaine du relais dans le tunnel) 2026-07-13 12:45:54 +02:00
nathaninline 9a93702313 web: panneau Acces OpenAI (endpoint /v1 + cle API masquee, gestion depuis le web)
Nouvel endpoint /api/apikey (GET etat + POST generate/set/clear -> restart).
Section sidebar affichant l'URL compatible OpenAI et la cle API masquee par
defaut (reveler/copier/generer/definir/retirer). Refactor config.go: helpers
genAPIKey/writeAPIKey/maskAPIKey partages CLI + web.
2026-07-13 12:39:37 +02:00
nathaninline d2e31ad6ee v0.2.21 : prefill = tokens réellement traités (prompt_n) + animation 3 points avant la première réponse 2026-07-10 11:57:32 +02:00
nathaninline e16c43715e v0.2.20 : corrige le tok/s de decode qui retombait à 0 en fin de génération (timings llama.cpp sur le chunk final sans choices, lus avant le garde) 2026-07-10 11:30:49 +02:00
nathaninline 18b8903603 doc : capture README en thème sombre 2026-07-10 10:47:53 +02:00
nathaninline 11dde9a2d5 doc : capture de l'interface web en haut du README 2026-07-10 10:44:44 +02:00
nathaninline acb08d3c6a v0.2.19 : preset actif de nouveau détecté (empreinte ignore les clés appareil MEM_MODE/CRAWL4AI_URL) + surbrillance bleue du preset actif 2026-07-10 09:49:06 +02:00
nathaninline 20dd09abc6 v0.2.18 : thèmes clair/sombre + refonte du panneau Mode agent + favicon/PWA Jean
- UI: système de thèmes sélectionnable (sombre par défaut, clair) — registre extensible
- UI: refonte propre du panneau Mode agent — Mémoire / Accès internet / Paramètres en cartes, liste des pages repliable
- UI: infobulles (?) à la place des pavés de texte, scrollbars natifs, suppression des touches de vert (palette bleu/blanc/gris)
- UI: favicon (coins arrondis) + icône PWA, nom d'app et titres « Jean »
- Mémoire et accès internet deviennent des sous-réglages du mode agent (aucun outil mem_*/web sans agent)
- Presets: le mode mémoire (MEM_MODE) et l'URL du serveur internet (CRAWL4AI_URL) sont conservés d'un preset à l'autre
2026-07-09 17:50:29 +02:00
nathaninline 07a6db88d1 doc : README — accès internet (Crawl4AI) + 3 modes de mémoire
Ajoute les fonctionnalités, les commandes `jean internet` / `jean memory`,
les clés config CRAWL4AI_URL / MEM_MODE et deux sections dédiées.
2026-07-08 11:10:11 +02:00
nathaninline c398cb85e6 v0.2.17 : accès internet (Crawl4AI) + 3 modes de mémoire
- accès web de l'IA via un serveur Crawl4AI configurable (outils
  web_search / web_open / web_read / web_grep), réglage CRAWL4AI_URL +
  toggle, actifs seulement si le mode agent et le serveur sont joignables.
  CLI `jean internet [on|off|status|url <url>]`.
- mémoire réglable en 3 modes indépendants du mode agent : off / sur
  demande / auto (proactive). CLI `jean memory [off|ondemand|always|status]`.
- fix : prompt et outils gouvernés par la même condition (plus de
  web_search tapé en bash quand le crawl est injoignable) ; consigne
  année de recherche durcie ; icône 🌐 des outils web dans l'UI.
2026-07-08 10:40:41 +02:00
nathaninline fbfb3effd7 v0.2.16 : support macOS expérimental (launchd) 2026-07-07 13:38:37 +02:00
nathaninline deb05f50ea macOS: support launchd (install/start/stop/logs) au lieu du code systemd hérité (#4) — non testé sur Mac. Isole le code Linux dans *_linux.go, ajoute *_darwin.go 2026-07-07 13:36:30 +02:00
nathaninline 2862a2f27a v0.2.15 : fix install symlink circulaire (#5) + indice dépendance Vulkan/SPIRV-Headers (#6) 2026-07-07 13:26:16 +02:00
nathaninline 2d4e91052b llamacpp: indice de dépendance adapté à la distro quand le build Vulkan échoue sur SPIRV-Headers manquant (fix #6) 2026-07-07 13:25:54 +02:00
nathaninline 357b814745 install: éviter le symlink circulaire si le binaire tourne déjà depuis /usr/local/bin/jean (fix #5) 2026-07-07 13:23:21 +02:00
nathaninline 5931bd72a6 v0.2.14 : PWA/UI mobile améliorée (plein écran standalone, gestion de l'encoche/safe-area, interface compacte en paysage, plus de zoom iOS à la saisie) + pastille d'état service (chargement/prêt) + gestion propre des erreurs de stream 2026-07-07 13:14:30 +02:00
nathaninline adcc6548f6 v0.2.13 : toggle limite d'appels d'outils + UI mémoire scalable (recherche/liste plafonnée) + modales natives (askConfirm/Prompt/Alert) + retouches CSS (crayon preset, badge mode agent) 2026-07-06 12:20:07 +02:00
nathaninline 6892aca506 UI : crayon edit preset calé dans le coin haut-droit + espace badge mode agent/flèche 2026-07-06 11:59:05 +02:00
nathaninline 5426772899 UI : modales natives au lieu de confirm()/prompt() du navigateur
Ajout d'un système de modale réutilisable askConfirm/askPrompt/askAlert
(Promise-based, Échap/clic-dehors = annuler, Entrée = valider) qui remplace
les 5 confirm()/prompt() natifs (auth, mode agent, switch preset, suppression,
compactage). _openAsk est polymorphe : accepte (message, opts) [UI] et l'objet
{title,msg,yes,no,placeholder} de e2e.js — sur ajean.link/server.html cette
fonction remplace le window.askConfirm du bootstrap boîte noire, donc doit
servir aussi les invites empreinte/appairage E2E.
2026-07-06 11:54:29 +02:00
nathaninline 102b700034 UI mémoire scalable : recherche + liste plafonnée + compteur
La liste des pages mémoire déroulait une barre géante quand elle grossit.
Désormais : compteur, champ de filtre (affiché au-delà de 50 pages), liste
scrollable (max-height 240px) et rendu plafonné par paliers de 50 ("voir
plus"). Rendu en DOM (échappement sûr) au lieu d'interpolation innerHTML.
2026-07-06 11:43:46 +02:00
nathaninline 5ef02672d2 mode agent : toggle web pour désactiver la limite d'appels d'outils (config TOOL_LIMIT)
La limite iter<8 (message [stop: trop d'appels d'outils]) est désormais
pilotée par TOOL_LIMIT dans config.env, exposée par un interrupteur sous
Mode agent. Activée par défaut ; désactivée => plafond quasi illimité
(l'anti-boucle sur appels répétés reste actif). Lu à chaque tour, pas de
restart nécessaire. Tunnelé tel quel via /api/e2e/req pour ajean.link.
2026-07-06 11:38:02 +02:00
nathaninline cc24074f69 i18n : descriptions d'outils + nudge passés en anglais (le français résiduel poussait le modèle à répondre en français même sur un 'hey') 2026-06-26 17:43:58 +02:00
nathaninline f9bceedebc v0.2.12 : mémoire (mem_search/read/add/edit) + outil edit + prompt agent court anti-stall qui cherche en mémoire avant toute tâche ; formulation clarifiée 2026-06-26 17:30:18 +02:00
nathaninline 0b03c0816e prompt agent : généralise la consigne de recherche mémoire (retire l'exemple météo trop spécifique) 2026-06-26 17:15:14 +02:00
nathaninline 2d6946af8b prompt agent : chercher en mémoire avant TOUTE tâche (how-to), même avec des spécifics (ville/nom) — fix 'météo pour X' qui improvisait 2026-06-26 17:08:32 +02:00
nathaninline c5526825ad prompt agent : insiste sur la gestion active de la mémoire (sauvegarde spontanée + recherche au rappel), stall vérifié à 0 2026-06-26 16:56:50 +02:00
nathaninline 7a145258d3 v0.2.11 : fix stall reasoning (prompt agent court) + outil edit + filet de sécurité nudge/drop_reasoning 2026-06-26 16:42:33 +02:00
nathaninline 4c8aafa898 v0.2.11 : mémoire (mem_search/read/add/edit) remplace les skills + outil edit (old/new) + run_shell renommé bash ; migration auto SKILLS->MEMORY 2026-06-26 15:59:24 +02:00
nathaninline d3a2f1c428 v0.2.10 : mode agent unifie (un seul toggle = shell + skills) + jeu de mot 'ajean' + refonte du header (pastille d'etat, preset, espacements) 2026-06-26 12:09:43 +02:00
nathaninline 175bbd34d9 v0.2.9 : UI web — reasoning/appels d'outil se replient au fil de la chaine + barre de contexte integree
- bulles reasoning/outil repliables (clic), repli fluide en diagonale (height+width animes en JS)
- chainage : chaque etape se replie des que la suivante demarre, la reponse finale replie la derniere
- compteur de tokens sur chaque bulle (reasoning : gen tok/s ; outil : ~taille reponse)
- bulles d'outil au meme design pointille transparent que le reasoning
- contexte : comptage exact via usage.prompt_tokens (system prompt inclus meme en cache KV)
- barre de contexte redessinee, integree au composer de saisie
2026-06-26 10:58:27 +02:00
nathaninline f632b7b42c v0.2.8 : l'IA garde la trace de ses appels d'outils entre les tours
Les tours d'outils (lecture de skill, commandes shell) ne survivaient
qu'a l'interieur de la boucle agentique d'un seul tour : seul le texte
final etait persiste. Resultat : a chaque nouveau message le modele
n'avait aucune trace d'avoir deja lu un skill, donc il le re-appelait
en boucle et confabulait des chemins/resultats disparus du contexte.

runChat retourne desormais les messages intermediaires (assistant
tool_calls + resultats tool). Le CLI les insere dans l'historique avant
la reponse finale ; le serveur web les emet en fin de flux SSE pour que
le client (stateless) les stocke. Couvre aussi ajean.link via E2E
(handleE2EChat -> runChatStream).
2026-06-25 21:26:20 +02:00
nathaninline a453229642 build: metadonnees Windows (versioninfo) + workflow release CI
Embarque editeur/version/description dans le .exe via goversioninfo (.syso)
pour reduire les faux positifs antivirus sur le binaire Go. Ajoute un
workflow GitHub Actions qui build les 6 cibles et publie la release sur tag.
2026-06-25 17:54:54 +02:00
nathaninline 76f644e449 v0.2.7 : jean link sûr — 'start' + aide, ne confond plus un mot avec un token
- 'jean link start' démarre le service (sous-commande dédiée)
- 'jean link' seul affiche l'aide des sous-commandes (ne démarre plus)
- un argument n'est traité comme token que s'il commence par 'jl_' ; sinon
  erreur + aide, SANS écraser le token enregistré
  (corrige le bug où 'jean link start'/typo écrasait le token -> 401 -> injoignable)
- message 'déjà en cours' au lieu d'un faux 'starté'
2026-06-25 13:55:47 +02:00
nathaninline e516ca7301 jean link: message 'déjà en cours' au lieu d'un faux 'starté' si le service tourne 2026-06-25 13:44:38 +02:00
nathaninline 97fbed8de2 v0.2.6 : heartbeat SSE anti-coupure (agents) + jean link en service (start/stop/restart/code)
- fix "TypeError: Load failed" en mode agent : heartbeat SSE (: ping /15s)
  pour que les longs silences (exécution d'outil) ne soient pas coupés en transit
- jean link devient le pilote du service (comme jean) ; worker = jean link serve
- jean link code : codes d'appairage à la demande, usage unique, TTL 10 min
  (store fichier haché, partagé entre le CLI et le worker)
- e2eauth.go ajouté au suivi ; README + aide mis à jour
2026-06-25 13:27:57 +02:00
nathaninline 949e261538 v0.2.5 : build llama.cpp 100% automatique et propre sur Windows et Linux
Windows : `jean llamacpp install` part de zéro et provisionne toute la
chaîne sans intervention :
- install Windows ajoute jean au PATH (copie dans %JEAN_HOME%\bin)
- auto-install des outils manquants via winget (cmake, git, ninja) +
  refresh du PATH du process depuis le registre
- compilateur MSVC Build Tools auto-installé ; générateur Visual Studio
  (localise MSVC sans Developer Prompt) ; binaire multi-config (Release\)
- détection GPU + auto-install du CUDA Toolkit quand une carte NVIDIA est
  vue sans nvcc ; CUDA_PATH/CUDA_PATH_Vx_y injectés pour MSBuild ; DLL
  runtime CUDA (bin + bin\x64) ajoutées au PATH de llama-server
- console UTF-8 + VT pour un affichage correct

Multi-OS :
- auto-install des outils aussi sous Unix (apt/dnf/pacman/brew)
- désactive l'UI web embarquée de llama-server (LLAMA_BUILD_UI=OFF +
  LLAMA_USE_PREBUILT_UI=OFF) : supprime la dépendance npm/HuggingFace qui
  cassait le build ; jean fournit sa propre UI
- build CUDA allégé (sans FA_ALL_QUANTS) : bien plus rapide
- sortie de build propre : configure/​build passent par un runner qui
  écrit le détail dans un log fichier et n'affiche qu'un spinner animé +
  le compteur de fichiers (formats MSBuild et Make/Ninja) + les erreurs
  réelles ; tests unitaires du filtre

Validé en exécution réelle : Windows (GTX 1650 Ti, sm_75) et serveur
Linux multi-GPU (sm_75;120) jusqu'au serveur GPU opérationnel.
2026-06-24 19:29:55 +02:00
nathaninline 747032cbf6 docs(README): retire l'instruction de config relais (privee) du endpoint /oai
Le relais est la partie hebergee/privee d'ajean.link : l'utilisateur n'y a
pas la main. Le README ne documente plus que la variable cote agent
(JEAN_LINK_ALLOW_OAI) et presente l'ouverture cote relais comme geree par
le service.
2026-06-24 14:39:30 +02:00
nathaninline 4e49cff0fe v0.2.4 : authentification mutuelle + anti-rejeu (boite noire 100%) 2026-06-24 13:32:30 +02:00
nathaninline d2b99bd6f4 Boite noire 100% : canal authentifie (identite utilisateur appairee) + anti-rejeu
- e2eauth.go : identites utilisateur appairees (.authorized_users), code
  d'appairage hors-bande (log jean link), canal authentifie
  K=SHA256(ECDH(uPriv,agentPriv)||authchan), AAD upub|ts, anti-rejeu.
- e2e.go : handleE2EChat/Req passent par e2eAuthOpenReq (plus de seal anonyme).
- link.go : route /api/e2e/pair + affiche le code d'appairage.
2026-06-24 13:26:13 +02:00
nathaninline 141512e9cb v0.2.3 : boite noire zero exception (controle serveur en E2E) 2026-06-24 12:56:39 +02:00
nathaninline 0e3951e401 Boîte noire « zéro exception » : proxy de contrôle chiffré E2E
- e2e.go : handleE2EReq, proxy de contrôle chiffré de bout en bout. Même
  enveloppe que le chat ; le clair décrit {method,path,body}, redispatché
  dans le handler web local authentifié (httptest), réponse {status,body}
  rechiffrée. Garde-fous : chemins /api/* hors /api/e2e, recover().
- link.go : le tunnel refuse tout /api/* en clair ; seuls /api/e2e/chat et
  /api/e2e/req passent. Le relais ne voit plus jamais de clair.
2026-06-24 12:53:31 +02:00
nathaninline 27606befe2 docs: README a jour pour la securite v0.2.2 (boite noire E2E, relais aveugle, /oai opt-in) 2026-06-23 19:55:24 +02:00
nathaninline a8a016c1b8 v0.2.2 : mise a jour de securite — boite noire E2E & relais aveugle
- Chiffrement E2E du chat navigateur<->agent (e2e.go) : le relais ne voit
  que de l'opaque, ancre par empreinte X25519 confirmee au `jean link`.
- `jean link` refuse desormais tout chat en clair via le tunnel (403) ;
  seul /api/e2e/chat porte du contenu. Endpoint OpenAI clair desactive par
  defaut (JEAN_LINK_ALLOW_OAI=1 pour le reactiver).
- Le portail web n'est plus servi par le relais : code livre par une origine
  hors de son controle, le relais devient un pur tube aveugle.
2026-06-23 19:45:17 +02:00
nathaninline ca5d3f9197 v0.2.1 : anti-boucle + system prompt facon pi
- Nouveau system prompt structure facon pi (identite -> methode/guidelines
  -> date -> outils + conseils -> contexte machine/skills), injecte via
  InjectSkills. Corrige les boucles de raisonnement infinies.
- Plafond de reflexion par defaut (REASONING_BUDGET=2048 au lieu de -1) :
  coupe une reflexion qui derape au lieu de tourner a l'infini.
- Garde-fou anti-boucle dans runChat : stop si le modele relance
  exactement le meme appel d'outil plusieurs tours de suite.
- Descriptions d'outils enrichies (run_shell, skill).
- Bump version 0.2.0 -> 0.2.1.
2026-06-21 09:14:08 +02:00
nathaninline e606206d90 Ajoute jean link : acces distant via le relais ajean.link
- nouvelle commande `jean link <token>` (link.go) : connexion sortante
  WebSocket + yamux vers le relais, sert l'UI web Jean ET l'endpoint
  compatible OpenAI (/v1) a travers le tunnel, avec injection des cles
  locales, keepalive et reconnexion automatique.
- web.go : extraction de newWebMux() pour reutiliser le routeur HTTP
  cote `jean link` sans repasser par un ecouteur TCP local.
- ui/index.html : base d'URL relative (API_BASE) + script marked relatif.
- main.go : cablage de la commande `link` + aide.
- README : section acces distant (ajean.link) + commande link.
- .gitignore : secrets runtime (.web_key/.link_token/.link_machine).

Inclut aussi les evolutions en cours du coeur de jean (llm.go, skills.go,
tools.go, chat.go).
2026-06-20 20:11:32 +02:00
nathaninline 6ea2e91810 Sync état à jour + fix perf GPU/CPU
- LD_LIBRARY_PATH inclut désormais les dossiers runtime CUDA
  (/usr/local/cuda*/lib64) : corrige le chargement du backend GPU.
- Unit systemd : Nice=-10 + CPUSchedulingPolicy=other (priorité CPU).
- Met à niveau le reste du projet (models, webauth, presets, web UI).
2026-06-17 13:21:25 +02:00
nathaninline 65b2ada540 Add files via upload 2026-06-09 10:06:56 +02:00
nathaninline 44637ac47f Add files via upload 2026-06-09 09:36:10 +02:00
nathaninline ca0a315260 Correction Windows 2026-06-09 09:35:07 +02:00
nathaninline a40c50c400 Update README.md 2026-06-08 15:54:12 +02:00
nathaninline 63cd01a27e Update README.md 2026-06-08 15:53:51 +02:00
nathaninline 5841e0f24c Add files via upload 2026-06-08 12:59:29 +02:00
nathaninline 46fab2edf4 Delete .gitattributes 2026-06-08 12:59:06 +02:00
nathaninline baeaeda20e Delete .gitignore 2026-06-08 12:58:55 +02:00
nathaninline 506934124e Add files via upload 2026-06-08 12:57:42 +02:00
nathaninline 28afbb7223 Create index.html 2026-06-08 12:57:24 +02:00
nathaninline 6802962120 Add files via upload 2026-06-08 12:54:49 +02:00
nathaninline 5d35ff0484 Initial commit 2026-06-08 12:54:03 +02:00
348 changed files with 62560 additions and 19004 deletions

No files matched your search

+8 -24
View File
@@ -1,26 +1,10 @@
# Contrôle de version
.git .git
.gitignore .github
data/
# Python models/
**/__pycache__ docs/
**/*.pyc dist/
backend/.venv *.md
backend/venv !README.md
# Node / build
frontend/node_modules
frontend/dist
**/.vite
frontend/tsconfig.tsbuildinfo
# Données & workspace de runtime (montés en volume)
data
workspace
# Environnement / IDE
.env .env
.env.local .env.*
.idea
.vscode
**/.DS_Store
+12 -24
View File
@@ -1,26 +1,14 @@
# ── Loki — configuration ──────────────────────────────────────────────── # ── Loki — configuration du build/run Docker ────────────────────────────
# Hôte Ollama. En Docker, host.docker.internal pointe vers la machine hôte. # (fork d'AJEAN : https://github.com/nathaninline/ajean)
# En local (hors Docker), utilise http://localhost:11434
OLLAMA_HOST=http://host.docker.internal:11434
# Modèle sélectionné par défaut au démarrage # Port de l'interface web (identique dedans/dehors).
DEFAULT_MODEL=llama3.1:8b LOKI_WEB_PORT=8090
# Dossier de travail de l'agent (monté en volume dans le conteneur) # Optionnel — semé au PREMIER démarrage seulement (modifiable ensuite dans
WORKSPACE_DIR=/workspace # l'UI, qui garde la main) :
# Modèle initial : nom d'un .gguf présent dans ./models ou chemin complet.
# Base SQLite (sessions, messages, config) LOKI_MODEL=
DATA_DIR=/data # Taille de contexte (défaut moteur : 32768)
LOKI_CTX=
# Port de l'application (identique dedans/dehors). 8717 par défaut. # Couches déportées sur le GPU (défaut : 999 = tout)
PORT=8717 LOKI_NGL=
# Optionnel : URL d'une instance SearxNG pour l'outil web_search.
# Si vide, web_search utilise DuckDuckGo (sans clé d'API).
SEARX_URL=
# Optionnel : VRAM du GPU (en Mo) pour l'auto-réglage, si Ollama tourne sur une
# autre machine (la détection nvidia-smi ne voit pas un GPU distant).
# Ex. 12000 pour une carte 12 Go. 0 = détection automatique.
GPU_VRAM_MB=0
GPU_NAME=
+1
View File
@@ -0,0 +1 @@
*.go text eol=lf
+51
View File
@@ -0,0 +1,51 @@
# CI : build + vet + gofmt + tests sur chaque push/PR.
name: ci
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: gofmt
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go build ./...
- run: go test ./...
# staticcheck : le projet est à zéro avertissement, et doit le rester.
# Les règles écartées et leur raison vivent dans staticcheck.conf.
- name: staticcheck
run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
- name: cross-compile (cibles release Linux + Windows)
run: |
# darwin n'est pas ici : depuis v0.5.4 l'icône de barre de menus passe
# par Cocoa (CGO) → les cibles macOS se compilent sur un runner Apple
# (job « macos » ci-dessous, comme dans release.yml).
for t in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
done
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go vet ./...
# Les tests tournent AUSSI ici : certains chemins ne s'exécutent que sur un
# vrai système de fichiers Unix (extraction des liens symboliques des
# archives llama.cpp, que Windows résout par une copie de repli).
- run: go test ./...
- name: build macOS (CGO, arm64 + amd64)
run: |
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o /dev/null ./cmd/loki
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \
CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \
go build -o /dev/null ./cmd/loki
+14
View File
@@ -16,6 +16,17 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# Depuis que l'image compile whisper.cpp avec CUDA, le build empile
# nvidia/cuda:*-devel (~6 Go), l'image runtime CUDA de llama.cpp (~4 Go)
# et Chromium/Playwright (~1 Go) — pour ~14 Go libres sur un runner neuf.
# Ces trois répertoires ne servent à rien ici et rendent une dizaine de
# gigaoctets en moins d'une minute.
- name: Libérer de l'espace disque
run: |
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
- name: Setup Buildx - name: Setup Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -45,5 +56,8 @@ jobs:
LOKI_VERSION=${{ github.sha }} LOKI_VERSION=${{ github.sha }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise les étapes whisperbuild-* (compilation
# whisper.cpp CUDA, ~35 min) tant que leurs lignes du Dockerfile ne
# bougent pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+52
View File
@@ -0,0 +1,52 @@
# Build macOS à la demande, SANS rien publier : produit Loki.app en artifact
# téléchargeable depuis l'onglet Actions (visible seulement par le dépôt).
# Sert à tester le bundle avant de décider d'une vraie release (release.yml).
name: mac-build
on:
workflow_dispatch:
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: build + bundle Loki.app (arm64)
run: |
mkdir -p dist
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \
go build -trimpath -ldflags="-s -w" -o dist/loki-macos-arm ./cmd/loki
mkdir -p /tmp/loki.iconset
sips -s format png 'cmd/loki/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true
for s in 16 32 128 256 512; do
sips -z $s $s /tmp/icon.png --out "/tmp/loki.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true
done
iconutil -c icns /tmp/loki.iconset -o /tmp/loki.icns >/dev/null 2>&1 || true
app="dist/Loki.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp dist/loki-macos-arm "$app/Contents/MacOS/loki"
chmod +x "$app/Contents/MacOS/loki"
cp /tmp/loki.icns "$app/Contents/Resources/loki.icns" 2>/dev/null || true
printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \
'<key>CFBundleName</key><string>Loki</string>' \
'<key>CFBundleDisplayName</key><string>Loki</string>' \
'<key>CFBundleExecutable</key><string>loki</string>' \
'<key>CFBundleIdentifier</key><string>link.loki.app</string>' \
'<key>CFBundleIconFile</key><string>loki</string>' \
'<key>CFBundlePackageType</key><string>APPL</string>' \
'<key>LSMinimumSystemVersion</key><string>11.0</string>' \
'<key>LSUIElement</key><true/>' \
'<key>NSHighResolutionCapable</key><true/>' \
'</dict></plist>' > "$app/Contents/Info.plist"
codesign --force --deep -s - "$app" || true
(cd dist && zip -qry loki-macos-arm.zip Loki.app && rm -rf Loki.app)
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: loki-macos-arm
path: dist/loki-macos-arm.zip
+138
View File
@@ -0,0 +1,138 @@
# Release : sur tag vX.Y.Z, compile les 6 cibles et publie la release GitHub
# avec les binaires (loki-linux, loki-macos, loki-windows.exe et leurs
# variantes -arm) ET le fichier SHA256SUMS que `loki update` vérifie avant
# d'installer. Ces noms doivent rester en phase avec assetNameFor (sys_update.go),
# sinon plus aucune machine ne se met à jour.
# ⚠️ NE PAS uploader de binaires à la main en plus : ce workflow écrase les
# assets du même nom (softprops remplace), et des sommes qui ne correspondent
# plus aux binaires font échouer `loki update` (vécu sur v0.4.7).
#
# Deux jobs de build : macOS a besoin d'un vrai runner Apple (l'icône de barre de
# menus passe par Cocoa → CGO, pas cross-compilable depuis Linux) ; Linux et
# Windows restent CGO_ENABLED=0 sur ubuntu.
name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: build macOS (CGO, arm64 + amd64)
run: |
mkdir -p dist
# arm64 = natif ; amd64 = cross avec le SDK universel (-arch x86_64).
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \
go build -trimpath -ldflags="-s -w" -o dist/loki-macos-arm ./cmd/loki
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \
CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \
go build -trimpath -ldflags="-s -w" -o dist/loki-macos ./cmd/loki
file dist/loki-macos*
- name: bundles Loki.app
run: |
# Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc
# aussi un vrai bundle Loki.app (zippé pour garder le bit exécutable) :
# au clic, le Finder lance Contents/MacOS/loki, qui détecte le bundle
# et bascule en mode « application » (UI web + navigateur + icône dans
# la barre de menus), comme le double-clic sur loki.exe sous Windows.
# LSUIElement : app de barre de menus, aucune icône dans le Dock.
# L'icône part du PNG 1024 généré avec le reste de la marque
# (cmd/loki/icon.png, voir tools/gen-icon) : plus de conversion depuis
# le .ico, dont l'échec silencieux donnait une app sans icône.
mkdir -p /tmp/loki.iconset
for s in 16 32 128 256 512; do
sips -z $s $s 'cmd/loki/icon.png' --out "/tmp/loki.iconset/icon_${s}x${s}.png" >/dev/null
done
iconutil -c icns /tmp/loki.iconset -o /tmp/loki.icns
v="${GITHUB_REF_NAME#v}"
for bin in loki-macos loki-macos-arm; do
app="dist/bundle-$bin/Loki.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "dist/$bin" "$app/Contents/MacOS/loki"
chmod +x "$app/Contents/MacOS/loki"
cp /tmp/loki.icns "$app/Contents/Resources/loki.icns" 2>/dev/null || true
printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \
'<key>CFBundleName</key><string>Loki</string>' \
'<key>CFBundleDisplayName</key><string>Loki</string>' \
'<key>CFBundleExecutable</key><string>loki</string>' \
'<key>CFBundleIdentifier</key><string>link.loki.app</string>' \
'<key>CFBundleIconFile</key><string>loki</string>' \
'<key>CFBundlePackageType</key><string>APPL</string>' \
"<key>CFBundleShortVersionString</key><string>$v</string>" \
"<key>CFBundleVersion</key><string>$v</string>" \
'<key>LSMinimumSystemVersion</key><string>11.0</string>' \
'<key>LSUIElement</key><true/>' \
'<key>NSHighResolutionCapable</key><true/>' \
'</dict></plist>' > "$app/Contents/Info.plist"
# Signature ad-hoc : sans elle, macOS refuse purement et simplement
# de lancer un binaire arm64 non signé (pas juste un avertissement).
codesign --force --deep -s - "$app" || true
(cd "dist/bundle-$bin" && zip -qry "../$bin.zip" Loki.app)
rm -rf "dist/bundle-$bin"
done
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: macos
path: dist/*
release:
needs: macos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: build Linux + Windows
run: |
mkdir -p dist
# Noms d'asset : loki-<os>[-arm][.exe]. Volontairement lisibles, et
# volontairement DIFFÉRENTS du schéma loki-<GOOS>-<GOARCH> des 0.7 :
# leur mise à jour automatique ne trouve alors aucun asset et échoue
# sans rien remplacer, au lieu d'installer un binaire 0.8 sur une
# machine encore agencée en 0.7 (voir updateAssetName, sys_update.go).
for t in linux/amd64:loki-linux linux/arm64:loki-linux-arm \
windows/amd64:loki-windows.exe windows/arm64:loki-windows-arm.exe; do
target=${t%%:*}; name=${t#*:}
os=${target%/*}; arch=${target#*/}; ldflags="-s -w"
# Windows : sous-système CONSOLE, pour que `cmd` attende la fin du
# programme et que redirections, tubes et commandes interactives
# fonctionnent. La console créée au double-clic est refermée par le
# programme lui-même (voir setupConsole, sys_console_windows.go).
GOOS=$os GOARCH=$arch CGO_ENABLED=0 \
go build -trimpath -ldflags="$ldflags" -o "dist/$name" ./cmd/loki
done
- uses: actions/download-artifact@v4
with:
name: macos
path: dist
- name: SHA256SUMS
run: |
chmod +x dist/loki-macos dist/loki-macos-arm
# Tous les assets commencent par « loki- », zips compris : un seul
# motif suffit. Ajouter « *.zip » les compterait DEUX fois, et un
# SHA256SUMS à doublons est exactement ce que `loki update` vérifie.
(cd dist && sha256sum loki-* > SHA256SUMS)
ls -l dist && cat dist/SHA256SUMS
- name: publier la release
uses: softprops/action-gh-release@v2
with:
files: dist/*
# Notes rédigées à la main, versionnées avec le code : le tag emporte
# donc ses notes, au lieu de publier une liste de commits auto-générée
# qu'il fallait ensuite remplacer à la main dans l'interface GitHub.
# ⚠️ Réécrire RELEASE_NOTES.md AVANT de poser le tag.
body_path: RELEASE_NOTES.md
generate_release_notes: false
+18 -21
View File
@@ -1,27 +1,24 @@
# Python # Binaires compilés
__pycache__/ /loki
*.pyc /loki.exe
.venv/ /dist/
venv/
*.egg-info/
# Node # Artefacts de build Go
node_modules/ *.exe
frontend/dist/ *.test
.vite/ *.out
# Data & workspace (runtime) # OS / éditeurs
data/*.db
data/*.sqlite3
workspace/*
!workspace/.gitkeep
# Env / IDE
.env
.env.local
.DS_Store .DS_Store
Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
# TS build cache # Ancienne application Python/React (fork pré-Go), plus utilisée : elle traîne
frontend/tsconfig.tsbuildinfo # encore sur certains postes de développement et ne doit JAMAIS revenir dans le
# dépôt — un « git add -A » distrait y a déjà versé 94 Mo de node_modules.
/backend/
/frontend/
.pytest_cache/
__pycache__/
node_modules/
+188 -35
View File
@@ -1,47 +1,200 @@
# ── Étape 1 : build du frontend ───────────────────────────────────────── # ── Loki — image GPU (fork d'AJEAN, https://github.com/nathaninline/ajean)
FROM node:20-alpine AS frontend #
WORKDIR /app/frontend # Deux étapes seulement :
COPY frontend/package.json frontend/package-lock.json* ./ # 1. compilation du binaire Go `loki` (UI embarquée via go:embed) ;
RUN npm install # 2. runtime = l'image serveur CUDA OFFICIELLE de llama.cpp — llama-server
COPY frontend/ ./ # y est précompilé et maintenu par l'équipe amont (base nvidia/cuda
RUN npm run build # runtime, backends .so dans /app, architectures GPU courantes).
# Aucune compilation CUDA ici : le build complet prend quelques minutes.
#
# Épingler une version : --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b10423
# Variante CPU (test sans GPU) : --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server
# Pré-requis d'exécution GPU : NVIDIA Container Toolkit sur l'hôte.
# ── Étape 2 : runtime backend (sert aussi le front statique) ──────────── # Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
FROM python:3.12-slim AS runtime ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
WORKDIR /app
ENV PYTHONUNBUFFERED=1 \ # ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
PYTHONDONTWRITEBYTECODE=1 \ # appartient à l'étape où il apparaît ; les « ARG WHISPER_CMAKE_FLAGS » nus des
WORKSPACE_DIR=/workspace \ # étapes whisperbuild-* héritent du scope GLOBAL — c'est-à-dire d'ici. Placé
DATA_DIR=/data \ # plus bas, ils héritaient d'une valeur VIDE : cmake tournait sans aucun
PORT=8717 # drapeau, ggml se construisait en bibliothèques partagées (échec de lien sur
# libcuda.so.1) et surtout en -march=native — le SIGILL de la PR #18 revenu en
# silence. Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
ARG WHISPER_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \
-DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF"
# curl pour le HEALTHCHECK ; git pour le moteur code (commits Aider) ; # ── Étape 1 : binaire Go loki ───────────────────────────────────────────
# nodejs/npm pour les serveurs MCP lancés via npx (Playwright, Context7…) FROM golang:1.25 AS gobuild
RUN apt-get update && apt-get install -y --no-install-recommends curl git nodejs npm \ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ cmd/
COPY internal/ internal/
COPY tools/ tools/
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# gopls : serveur LSP Go, consommé par le vérificateur de code du mode code
# (lsp.go). Compilé ici parce que l'image finale n'a pas de toolchain Go.
# GOTOOLCHAIN=auto : gopls@latest exige régulièrement un Go plus récent que
# celui de l'image (v0.23 veut ≥ 1.26 quand l'image est en 1.25) ; en local,
# l'installation échouait net. Auto télécharge le toolchain requis — coût
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 bis : whisper-server (dictée vocale) ────────────────────────
# DEUX binaires sont construits, pas un. LLAMACPP_IMAGE accepte la variante
# CPU de l'image amont (voir ligne 11) : sur cette base, les bibliothèques
# CUDA sont absentes et un binaire lié à CUDA ne démarre pas du tout —
# l'éditeur de liens échoue avant la première instruction, donc aucun repli
# n'est possible depuis l'intérieur du programme. Loki choisit à l'exécution
# (whisperServerBin, dictate_server.go).
#
# whisper-server et non whisper-cli : le modèle reste chargé entre deux
# dictées. L'ancien chemin le relisait depuis le disque à chaque phrase, ce
# qui dominait le temps de réponse.
#
# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE sur LES DEUX cibles. Par défaut ggml
# compile en -march=native, c'est-à-dire pour le processeur DU RUNNER DE
# BUILD — un Xeon récent chez GitHub, avec AVX-512 et AMX. Le binaire partait
# alors sur une machine qui n'a pas ces instructions et mourait d'un SIGILL en
# pleine transcription : « AMX is not ready to be used! », puis plus rien,
# l'interface affichant un échec sans raison. OFF retombe sur la ligne de base
# AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis 2013.
# Ubuntu 22.04 : glibc plus ancienne que l'image runtime, donc compatible quoi
# qu'elle embarque.
FROM ubuntu:22.04 AS whisperbuild-cpu
ARG WHISPER_CMAKE_FLAGS
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Le grep final vérifie que ggml est bien lié EN STATIQUE : l'image finale ne
# reçoit que le binaire, une .so ggml manquante ne se verrait qu'au premier
# clic sur le micro. Des libs partagées ici = drapeaux non transmis.
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} \
&& cmake --build /w/build -j"$(nproc)" --target whisper-server \
&& ( ! ldd /w/build/bin/whisper-server | grep -E "libggml|libwhisper" )
# Version CUDA : la MÊME que celle de l'image amont (llama.cpp bâtit avec
# CUDA 12.8.1 sur Ubuntu 24.04). Le binaire est lié dynamiquement à libcudart,
# fournie par LLAMACPP_IMAGE et non par cette étape.
#
# ⚠️ 12.8 est un PLANCHER, pas un détail de version. Les GPU Blackwell
# (RTX 50xx, sm_120) n'existent pas pour un nvcc 12.4 : il refuse l'archi, et
# à défaut le binaire ne tourne que par recompilation PTX au chargement, quand
# elle est possible.
#
# ⚠️ -j"$(nproc)" et JAMAIS -j nu. Avec Make, « -j » sans nombre autorise un
# parallélisme ILLIMITÉ. ggml-cuda compte ~200 fichiers d'instanciation de
# gabarits, et chaque nvcc réclame 1 à 2 Go : le runner GitHub (16 Go) était
# tué par l'OOM après avoir lancé 92 % des compilations en treize secondes,
# sans écrire la moindre ligne d'erreur. L'étape CPU y survivait — peu de
# fichiers, compilation légère — ce qui rendait le piège invisible jusqu'ici.
#
# CUDA_ARCHS borne le travail : ggml compile sinon pour TOUTES les
# architectures qu'il connaît, de Maxwell à Blackwell, et chacune multiplie le
# temps de compilation. La liste par défaut couvre Turing à Blackwell
# (RTX 20xx → 50xx) ; l'élargir pour un GPU plus ancien se fait sans toucher
# au fichier : --build-arg CUDA_ARCHS="61;75;86".
FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 AS whisperbuild-cuda
ARG WHISPER_CMAKE_FLAGS
ARG CUDA_ARCHS="75;86;89;120"
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Même vérification statique que l'étape CPU. libcuda.so.1 (le PILOTE) reste
# elle une dépendance dynamique normale : absente du runner de build, injectée
# à l'exécution par le NVIDIA Container Toolkit — l'édition de liens la
# résout via les stubs du toolkit.
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} -DGGML_CUDA=ON \
-DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \
&& cmake --build /w/build -j"$(nproc)" --target whisper-server \
&& strip /w/build/bin/whisper-server \
&& ( ! ldd /w/build/bin/whisper-server | grep -E "libggml|libwhisper" )
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
# Marqueur de build (sha court), injecté par le workflow GitHub.
ARG LOKI_VERSION=dev
LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
org.opencontainers.image.source="https://github.com/R0m1k3/Loki" \
org.opencontainers.image.description="Loki — fork conteneurisé d'AJEAN (github.com/nathaninline/ajean, MIT)"
# git pour les outils de l'agent ; nodejs/npm pour les serveurs MCP lancés
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
# et la plupart des serveurs MCP réclament Node ≥ 20.
RUN apt-get update && apt-get install -y --no-install-recommends \
git tini ca-certificates curl gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./ # Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
RUN pip install --no-cache-dir -r requirements.txt mcp-server-fetch # npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
# langage — mais autant livrer l'image complète.
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
COPY backend/ ./backend/ # uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
# Frontend compilé servi en statique par FastAPI # -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
COPY --from=frontend /app/frontend/dist ./backend/static # s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
RUN mkdir -p /workspace /data # Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
# légère — l'outil se désactive alors tout seul (le binaire absent est détecté).
ARG PLAYWRIGHT=1
ARG PLAYWRIGHT_VERSION=latest
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers
# Les polices sont INDISPENSABLES : sans elles, Chromium rend les pages sans
# AUCUN texte — boutons et titres sortent vides, seuls les images et les aplats
# apparaissent. Noto couvre l'essentiel des écritures, Liberation fournit les
# substituts d'Arial/Times que réclament la plupart des sites.
RUN if [ "$PLAYWRIGHT" = "1" ]; then \
npm i -g playwright@${PLAYWRIGHT_VERSION} \
&& playwright install --with-deps chromium \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
fonts-liberation fonts-dejavu-core fonts-noto-core \
fonts-noto-cjk fonts-noto-color-emoji \
&& fc-cache -f \
&& rm -rf /root/.npm /var/lib/apt/lists/* ; \
fi
# Marqueur de version (git sha court), injecté par le workflow de build. COPY --from=gobuild /out/loki /usr/local/bin/loki
ARG LOKI_VERSION=dev COPY --from=gobuild /out/gopls /usr/local/bin/gopls
ENV LOKI_VERSION=${LOKI_VERSION} # Dictée vocale (POST /api/transcribe). Les modèles (190 Mo à 1,1 Go) ne sont
# PAS dans l'image : téléchargés à la demande dans /data/whisper/.
# Deux binaires : voir l'étape whisperbuild-cpu pour la raison.
COPY --from=whisperbuild-cpu /w/build/bin/whisper-server /usr/local/bin/whisper-server-cpu
COPY --from=whisperbuild-cuda /w/build/bin/whisper-server /usr/local/bin/whisper-server-cuda
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
# NB : on tourne en root par défaut pour rester compatible avec les volumes # llama-server et ses .so vivent dans /app (convention de l'image amont).
# montés d'Unraid (/mnt/user/appdata/...), souvent détenus par root. Pour ENV LOKI_CONTAINER=1 \
# durcir, surcharge l'utilisateur côté compose (ex. user: "99:100"). LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_WHISPER_SERVER_CPU=/usr/local/bin/whisper-server-cpu \
LOKI_WHISPER_SERVER_CUDA=/usr/local/bin/whisper-server-cuda \
LD_LIBRARY_PATH=/app \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
EXPOSE 8717 # 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
WORKDIR /app/backend # conteneur — il n'est pas authentifié par défaut.
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ # Remplace le HEALTHCHECK de l'image amont (qui vise le moteur sur :8080).
CMD curl -fsS "http://localhost:${PORT}/api/health" || exit 1 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS "http://localhost:${LOKI_WEB_PORT:-8090}/" >/dev/null || exit 1
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"] WORKDIR /data
# Remplace l'ENTRYPOINT de l'image amont (/app/llama-server).
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jean contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35
View File
@@ -0,0 +1,35 @@
# Avis d'attribution
**Loki est un fork de [AJEAN](https://github.com/nathaninline/ajean)**, créé
par [nathaninline](https://github.com/nathaninline) et publié sous licence MIT.
L'essentiel du code de ce dépôt — moteur d'assistant IA en Go, interface web
embarquée, mémoire persistante, accès internet, outils MCP, accès distant
chiffré — est l'œuvre du projet AJEAN. Merci à son auteur.
La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
## Modifications apportées par le fork
- **Renommage** AJEAN → Loki (binaire `loki`, `LOKI_HOME`, `/etc/loki`,
units `loki-engine` / `loki-ui`).
- **Conteneurisation** : image Docker autonome (llama.cpp compilé CUDA +
binaire Go), `docker-compose` et variante Unraid, publication GHCR.
- **Supervision sans systemd** (`sys_service_container.go`) : en conteneur,
le moteur est piloté par fichier PID au lieu de systemctl — indispensable
pour que l'UI puisse redémarrer le moteur (changement de modèle).
- **`loki config get/set`** : lecture/écriture non interactive de la
configuration, utilisée par l'entrypoint Docker.
- **Mode code** (`internal/loki/code_*.go`, `lsp.go`, `agents/`) : agent de
code avec critères d'acceptation, passe de vérification indépendante,
outils read/grep/glob, politique d'exécution, diagnostics LSP, outils git,
jobs d'arrière-plan et relance sur appel d'outil textuel. La conception
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
patterns, table languages.json) est reprise
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
pour ce fork ; les prompts de rôles sont des réécritures originales.
## Services externes
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
opéré par l'auteur d'AJEAN ; le catalogue de modèles intégré est également
servi par ajean.link. Ces services appartiennent au projet amont.
+394 -265
View File
@@ -1,309 +1,438 @@
# Loki — Agent IA local sur Ollama # Loki — assistant IA local en conteneur (fork d'AJEAN)
Loki est un atelier d'agent IA **100 % local**, conçu pour se connecter à > **Loki est un fork de [AJEAN](https://github.com/nathaninline/ajean)** de
[Ollama](https://ollama.com) et travailler en mode agentique : un tchat, des > [nathaninline](https://github.com/nathaninline), sous licence MIT — voir
outils (lecture/écriture de fichiers, aperçu HTML en direct…) et un workspace > [`NOTICE.md`](NOTICE.md) et [`LICENSE`](LICENSE). L'essentiel du code et des
de fichiers. Pensé pour un déploiement **Docker** simple. > fonctionnalités vient d'AJEAN ; ce fork le rebaptise et le fait tourner dans
> **un conteneur Docker GPU autonome**, là où l'amont s'installe en binaire +
> systemd sur la machine hôte.
Le thème visuel (« atelier café », sombre et chaleureux, accent ambre) est Loki fait tourner un modèle de langage **100 % en local** : discussions
décliné fidèlement depuis la maquette d'origine. multiples, mémoire persistante, accès internet, captures de pages web, outils
MCP, agent (shell, fichiers) — serveur d'inférence llama.cpp compris, dans une
seule image.
## Architecture ## Architecture
``` ```
Frontend (React + Vite + TS + Tailwind) ┌────────────────── conteneur loki ──────────────────┐
│ HTTP + SSE │ loki web (UI + API, port 8090, premier plan) │
Backend (FastAPI, Python) │ │ pilote (fichier PID — pas de systemd) │
├── /api/status, /api/models, /api/models/pull (Ollama) │ loki serve ──exec──► llama-server (CUDA, :8080) │
├── /api/sessions (CRUD sessions) │ ▲ modèles .gguf │
├── /api/chat (boucle agentique + outils, streaming SSE) │ chromium (Playwright) — captures de pages web │
├── /api/files (arborescence + contenu du workspace) │ /data (config, bbolt, presets, mémoire, │
│ Outils agent : read_file · write_file · list_dir (confinés au workspace) │ workspace par discussion, modèles) │
│ httpx │ volume │ /models (GGUF déposés à la main) │
Ollama (:11434) /workspace + /data (SQLite) └────────────────────────────────────────────────────┘
``` ```
Un seul conteneur : le backend FastAPI sert l'API **et** le frontend compilé. - **Un seul conteneur** : l'UI joint le moteur sur `localhost` (contrainte
Loki se connecte à un **Ollama existant** via `OLLAMA_HOST`. héritée de l'amont), les deux partagent donc le même conteneur.
- **Sans systemd** : l'amont pilote le moteur via systemctl ; en conteneur,
Loki bascule automatiquement sur une supervision par fichier PID
(`internal/loki/sys_service_container.go`). Changer de modèle depuis l'UI
redémarre le moteur normalement.
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
seule l'UI (8090) l'est.
## Démarrage rapide (Docker) ## Démarrage rapide (Docker, GPU NVIDIA)
Pré-requis : pilote NVIDIA + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html).
```bash ```bash
cp .env.example .env # ajuste OLLAMA_HOST si besoin cp .env.example .env
docker compose up --build docker compose up --build # quelques minutes : llama-server vient précompilé
# de l'image officielle llama.cpp (server-cuda)
``` ```
Application disponible sur http://localhost:8717 Interface : http://localhost:8090 — installe un modèle depuis la recherche
Hugging Face intégrée (voir ci-dessous), il démarre tout seul.
> **Ollama** : par défaut Loki vise `http://host.docker.internal:11434` ## Installer un modèle
> (un Ollama installé sur la machine hôte). Pour embarquer Ollama dans la
> stack : `docker compose --profile ollama up` puis règle Dans l'éditeur de preset, **Chercher un modèle** interroge Hugging Face et ne
> `OLLAMA_HOST=http://ollama:11434`. remonte que les dépôts GGUF. Choisir un dépôt déplie ses quantifications avec
leur taille et un verdict mémoire (`ok` / `juste` / `trop`) calculé sur la VRAM
réellement détectée — ou sur la RAM système s'il n'y a pas de GPU. C'est une
estimation : le coût exact du cache KV dépend de l'architecture du modèle, que
la seule liste des fichiers ne révèle pas.
Si le dépôt publie un projecteur vision (`mmproj-*.gguf`), Loki propose de
l'installer avec le modèle et remplit le champ **Vision** du preset. C'est le
seul moyen fiable d'avoir la vision : un projecteur encode dans l'espace latent
de **son** modèle, donc un `mmproj` pris dans un autre dépôt donne un moteur qui
démarre et ne voit rien. Quand le dépôt n'en publie pas, Loki le dit plutôt que
d'aller en chercher un ailleurs.
Deux repères pour choisir un dépôt :
- `unsloth/*` publie des quantifications **Dynamic** (`UD-Q4_K_XL`,
`UD-IQ3_XXS`…) qui gardent en plus haute précision les tenseurs sensibles :
à taille égale, elles se tiennent mieux qu'un `Q4_K_M` classique.
- `ggml-org/*` est le dépôt de référence de l'équipe llama.cpp — c'est en
général là que le projecteur vision est publié en premier.
Le champ **Télécharger un modèle** reste disponible pour coller un lien direct
(dépôt privé, fichier hors des conventions). Un dépôt à accès restreint demande
la variable d'environnement `HF_TOKEN`.
Certains dépôts sont **à accès restreint** (« gated ») : leur arborescence se lit
sans rien, mais chaque `.gguf` répond `401` tant que les conditions du dépôt
n'ont pas été acceptées sur huggingface.co **et** qu'un jeton n'est pas fourni.
Loki les marque « accès restreint » dès la liste des résultats et rappelle le
geste à faire, plutôt que de laisser choisir une quantification pour échouer au
lancement du transfert.
Le jeton se règle **dans l'interface** : éditeur de preset → *Modèle* → **Jeton
Hugging Face**. Il est vérifié auprès de Hugging Face avant d'être enregistré
(le compte associé s'affiche), rangé avec les autres secrets sous `/data` — donc
il survit aux redémarrages et aux changements de preset — et il sert aussi bien à
la recherche qu'au téléchargement. À défaut, la variable d'environnement
`HF_TOKEN` reste lue comme avant ; le jeton enregistré dans l'interface a la
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
n'est envoyé qu'aux adresses Hugging Face.
## Installation sur Unraid ## Installation sur Unraid
L'image est **construite et publiée automatiquement par GitHub Actions** sur GHCR L'image est construite et publiée par GitHub Actions sur GHCR
(`ghcr.io/r0m1k3/loki:latest`) à chaque push sur `main`. Aucun build ni `git` (`ghcr.io/r0m1k3/loki:latest`) à chaque push sur `main` — aucun build sur
n'est nécessaire sur Unraid — un simple `pull`. Compose prêt à l'emploi : Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose.unraid.yml).
[`docker-compose.unraid.yml`](docker-compose.unraid.yml).
1. Dans le terminal Unraid, crée les dossiers de données : 1. Installe le plugin **Nvidia Driver** (Apps) et vérifie `nvidia-smi`.
2. Crée les dossiers :
```bash ```bash
mkdir -p /mnt/user/appdata/loki/workspace /mnt/user/appdata/loki/data mkdir -p /mnt/user/appdata/loki/data /mnt/user/appdata/loki/models
``` ```
2. Installe le plugin **Compose Manager** (Apps), crée une nouvelle stack, et 3. Plugin **Compose Manager** → nouvelle stack → colle
colle le contenu de `docker-compose.unraid.yml`. `docker-compose.unraid.yml` → **Compose Up**.
3. **Adapte `OLLAMA_HOST`** : mets l'IP de ton serveur Unraid où tourne le 4. Interface : `http://<ip-unraid>:8090`.
conteneur Ollama, p. ex. `http://192.168.1.10:11434`.
4. **Compose Up**. Loki est accessible sur `http://<ip-unraid>:8717`
(change le port à gauche du mapping `8717:8080` s'il est déjà pris).
> La première publication de l'image prend quelques minutes (le temps que le ## Configuration
> workflow GitHub se termine). Si l'image est privée, rends le package **public**
> une fois (GitHub → Packages → `loki` → Package settings → Change visibility).
>
> Les modèles déjà présents dans ton Ollama sont **détectés automatiquement** ;
> tu peux en télécharger d'autres depuis l'onglet Configuration.
> Mise à jour : Compose Down/Up (avec pull), ou `docker compose pull`.
## Développement (sans Docker) Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
redémarrages (volume `/data`). Variables d'environnement du conteneur :
| Variable | Rôle | Défaut |
|---|---|---|
| `LOKI_WEB_PORT` | port de l'UI | `8090` |
| `LOKI_MODEL` | modèle initial (semé au 1er boot seulement) | — |
| `LOKI_CTX` | taille de contexte initiale | `32768` |
| `LOKI_NGL` | couches GPU initiales | `999` (tout) |
| `LOKI_HOME` | données (volume) | `/data` |
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `test`…).
### Données et persistance
**Tout** l'état vit sous `/data` (= `LOKI_HOME`). Si ce chemin n'est pas un
volume monté sur l'hôte, il disparaît au premier `docker compose down` — presets
et modèles compris.
| Chemin | Contenu |
|---|---|
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
| `/data/models/` | modèles téléchargés depuis l'interface |
| `/data/memory/` | pages de mémoire persistante (`.md`) |
| `/data/workspace/` | racine du dossier de travail de l'agent |
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
| `/models` | GGUF déposés à la main depuis l'hôte (volume séparé, `LOKI_MODEL_DIRS`) |
Vérifier que le volume est bien là :
**Backend**
```bash ```bash
cd backend docker inspect loki --format '{{range .Mounts}}{{.Source}} → {{.Destination}}{{println}}{{end}}'
pip install -r requirements.txt
OLLAMA_HOST=http://localhost:11434 uvicorn app.main:app --reload --port 8080
``` ```
**Frontend** (proxy `/api` → `:8080`) Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/user/…`
(partage, via FUSE) et `/mnt/cache/…` (disque de cache) désignent des
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
redémarrage de l'array). Vérifie avec la commande `docker inspect` ci-dessus :
il doit y avoir une ligne `… → /data` **et** une `… → /models`. S'il n'y en a
pas, ton conteneur a été lancé sans mapping (template Docker Unraid incomplet,
`docker run` sans `-v`) — recrée-le avec les volumes du compose fourni. Depuis
cette version, Loki le détecte au démarrage : bandeau rouge dans l'UI et
avertissement en tête du journal du conteneur.
Autre piège au redémarrage du serveur : Docker relance les conteneurs
`restart: unless-stopped` **avant** que le plugin Nvidia Driver ait chargé ses
modules. Le journal montre alors des `ERROR: init … result=11` et le moteur
démarre sans GPU. Un `docker restart loki` une fois le pilote prêt suffit.
## Fonctionnalités
Héritées d'AJEAN :
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
conversations. La **vision** demande un modèle multimodal *et* son projecteur
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
- **Mémoire persistante** (`memory off|ondemand|always`).
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les
lancements suivants partent du cache en quelques secondes.
- **Tâches planifiées** : une consigne que l'IA exécute **toute seule**, sur une
fréquence réglable (« toutes les 2 h », « tous les jours à 9 h », ou une
expression cron). Chaque tâche tourne **isolée des discussions** — elle n'écrit
pas dans le fil, travaille dans son propre dossier (`workspace/tasks/<id>/`) et
livre son résultat par les outils de l'IA (mail via MCP, shell, fichiers…) ; le
compte-rendu du dernier passage est visible dans sa fiche, et **réinjecté** au
passage suivant pour la continuité. Réglages par tâche : preset (donc modèle) à
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
tâche attend son tour, et le bouton stop du chat l'interrompt.
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
valeurs partent dans **chaque requête** au moteur, pas sur sa ligne de commande :
les changer ne demande donc aucun redémarrage. Un champ laissé vide n'envoie
rien et llama-server garde son défaut — utile parce que le défaut de llama.cpp
(top_k 40, min_p 0.05) est rarement celui que recommande le modèle (Qwen3.8 en
réflexion veut top_k 20, min_p 0, temp 1.0, top_p 0.95).
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
fork la sert autrement que l'amont).
Ajoutées par ce fork :
- **Mode Code** : chaque discussion a un sélecteur **Chat | Code** dans le pied
de la carte de saisie. En mode Code, Loki devient un agent de code : outils
`read`/`grep`/`glob` (lecture bornée, numéros de ligne — et un fichier doit
avoir été LU avant d'être modifié), outils git (`git_status`, `git_diff`,
`git_clone` — le clone atterrit dans le dossier de la discussion), jobs
d'arrière-plan (`bash_bg`/`bash_tail` pour un serveur de dev ou un build
long), et **critères d'acceptation** : le modèle pose le contrat (2-6
critères testables, éditables dans le panneau au-dessus de la saisie), puis
une **passe de vérification indépendante** — même modèle, contexte isolé,
seule habilitée à marquer un critère « passé » — contrôle le diff et relance
la correction jusqu'à ce que tout passe (2 corrections max). Les fichiers
modifiés passent au **LSP** (gopls, typescript-language-server, pyright —
inclus dans l'image) : les erreurs de compilation reviennent dans le résultat
de l'outil, sans lancer de build. Sécurité : commandes catastrophiques
refusées (rm -rf /, mkfs, reboot…), chemins bornés au dossier de la
discussion en mode Code. En mode Chat, un message qui ressemble à une tâche
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
jamais bascule automatique. Conception reprise
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
`NOTICE.md`.
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
playwright, context7, github…) avec leur commande déjà renseignée, classés par
catégorie. Choisir une entrée **n'installe rien** : ça remplit le formulaire
d'ajout, tu relis la commande — qui s'exécutera sur cette machine — puis tu
enregistres. Le catalogue est un JSON **embarqué dans le binaire**
(`internal/loki/mcp_catalog.json`), donc aucun appel à un annuaire distant :
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
- **Intensité du raisonnement** : un niveau — auto / aucune / basse / moyenne /
haute / maximale — envoyé à `llama-server` comme `reasoning_effort`. Réglable
**dans la barre de saisie**, parce que ça se décide en écrivant le message : le
changement s'applique au message suivant, sans redémarrer le moteur. L'éditeur
de preset garde le même réglage comme **défaut du modèle** ; appliquer un
preset reprend donc la main sur le choix fait à la volée. `none` coupe le
raisonnement ; les autres valeurs sont passées au gabarit jinja du modèle, ce
qui ne change le comportement que des modèles qui les lisent (gpt-oss et
apparentés) — ailleurs c'est ignoré sans erreur, et l'interface le dit plutôt
que de promettre un effet. Aucun gabarit ne les connaît toutes (gpt-oss :
basse/moyenne/haute ; Qwen3.8 : basse/moyenne/maximale) et certains **refusent**
celles qu'ils ne connaissent pas, avec une erreur 500 qui tuait le tour : loki
lit alors les niveaux annoncés par le refus, **repli sur le plus proche** (haute
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
raisonnement est coupé pour ce modèle.
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
le disque ne se remplisse pas en silence.
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
(Chromium via Playwright, inclus dans l'image). Les captures partent en JPEG
et sont plafonnées à 20 fichiers / 40 Mo par discussion. La description de
l'outil suit la capacité **réelle** du moteur, sondée sur `/props` : sans
vision effective, elle dit au modèle « tu ne vois pas l'image » plutôt que de
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
aucune requête vers un service de polices. Deux variantes : claire par défaut,
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
visibles en pied de colonne.
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
du moteur. Conséquence directe : l'API est joignable partout où l'interface
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
détectée est celle du bridge Docker).
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
l'interface le dit en rouge.
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
affichée suit celle du navigateur.
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
- L'ancienne exposition publique via le relais de l'amont
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
pouvait donc qu'échouer.
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
`AGENT_BUDGET=off` le désactive complètement.
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
dans le fil.
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
volets — la nav des sections à gauche (IA, moteur, application), le panneau
choisi à droite. La barre latérale ne garde que les discussions (les plus
récentes en tête) et le moniteur machine.
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
survit au rechargement, et un vieux tour garde le modèle de l'époque.
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
l'affichage ne se fige plus.
Retirées par ce fork :
- **Accès distant via [ajean.link](https://ajean.link)** : la section de
l'interface et son module JS sont supprimés — un conteneur derrière son
propre réseau n'en a pas l'usage. Le code serveur du relais reste en place
mais **inerte** (aucun jeton, aucune section pour en fournir un) : le retirer
créerait un conflit à chaque reprise de l'amont.
- **Postes distants** (faire agir l'agent sur un autre PC appairé) : bouton du
composeur, modales d'appairage et module JS supprimés. Même traitement que
ci-dessus — les routes `/api/node/*` subsistent mais plus rien ne peut
générer de code d'appairage, donc aucun poste ne peut se connecter.
- **Catalogue de modèles distant** : il interrogeait `ajean.link/models.json`,
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
recherche Hugging Face le remplace.
## Différences avec l'amont
| | AJEAN (amont) | Loki (ce fork) |
|---|---|---|
| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` |
| Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | image officielle llama.cpp (`server-cuda`), précompilée |
| Panneau « Moteur » | propose d'installer/compiler | affiche la version qui tourne et la met à jour en un clic (sans rebuild) |
| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) |
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
| Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié |
| Historique de tchat | conversation unique | discussions multiples, titrées et persistées |
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
Pour récupérer les évolutions de l'amont :
```bash ```bash
cd frontend git fetch upstream && git merge upstream/main # conflits de renommage à arbitrer
npm install
npm run dev # http://localhost:5173
``` ```
## Configuration (variables d'environnement) ## Build sans GPU / autres accélérateurs / version épinglée
| Variable | Défaut | Rôle | L'image Loki se construit **au-dessus de l'image serveur officielle de
| --------------- | ----------------------------------- | ----------------------------- | llama.cpp**, choisie par le build-arg `LLAMACPP_IMAGE` :
| `OLLAMA_HOST` | `http://host.docker.internal:11434` | URL de l'instance Ollama |
| `DEFAULT_MODEL` | `gemma4:12b` | Modèle sélectionné au démarrage |
| `WORKSPACE_DIR` | `/workspace` | Dossier de travail de l'agent |
| `DATA_DIR` | `/data` | Base SQLite (sessions + config) |
| `PORT` | `8717` | Port de l'application (dedans = dehors) |
| `SEARX_URL` | *(vide)* | Instance SearxNG pour `web_search` (sinon DuckDuckGo) |
## Utilisation ```bash
# CPU seul (test sans GPU)
1. Vérifie la pastille **Ollama** (verte = connecté) en haut à droite, et choisis docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server .
un modèle qui supporte le *function calling* (profil fourni : `gemma4:12b`). # Vulkan (GPU AMD/Intel/NVIDIA sans CUDA)
2. Décris une tâche dans le tchat, p. ex. *« Crée une landing page pour un café docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-vulkan .
nommé Café Lumière, avec menu et horaires »*. # Version de llama.cpp épinglée (reproductible)
3. L'agent lit/écrit des fichiers dans le **workspace** ; chaque appel d'outil docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b10423 .
s'affiche dans le fil, et l'aperçu HTML apparaît à droite (onglets **Aperçu /
Code / Logs**).
4. Règle le comportement dans **Configuration** (modèle, température/top-p/top-k,
jetons max, outils actifs, invite système).
## Outils de l'agent
| Outil | Rôle | Par défaut |
| ------------- | -------------------------------------- | ---------- |
| `read_file` | Lire un fichier du workspace | activé |
| `write_file` | Créer / modifier un fichier | activé |
| `list_dir` | Lister un répertoire | activé |
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
| `run_shell` | Exécuter une commande **(sensible)** | désactivé |
## Préchargement des modèles (réponses instantanées)
Ollama décharge un modèle de la VRAM après quelques minutes d'inactivité : le
message suivant paie alors un rechargement complet (lent). Loki évite ça :
- **Préchargement à la sélection** : choisir un modèle le charge immédiatement
en VRAM (`/api/models/warm`).
- **Maintien au chaud** : chaque requête envoie un `keep_alive` (défaut 30 min,
réglable dans Configuration → Intelligence : de « décharger aussitôt » à
« toujours »).
- **Préchargement au démarrage** du modèle par défaut (en arrière-plan).
- **Indicateur d'état** : la pastille du sélecteur de modèle est verte quand le
modèle est chargé sur GPU, orange sur CPU, blanche s'il reste à charger.
## Performance Ollama (recommandé)
Réglages **côté serveur Ollama** qui rendent Loki nettement plus fluide
(variables d'environnement du service/conteneur Ollama, redémarrage requis) :
| Variable | Valeur conseillée | Effet |
| -------- | ----------------- | ----- |
| `OLLAMA_KEEP_ALIVE` | `30m` (ou `-1`) | Durée de rétention par défaut d'un modèle en VRAM. `-1` = jamais déchargé (machine dédiée). Doit être ≥ au `keep_alive` configuré dans Loki. |
| `OLLAMA_MAX_LOADED_MODELS` | `2` | Autorise le modèle de chat **et** le modèle d'embedding (RAG) à résider ensemble en VRAM — supprime les allers-retours de chargement à chaque message. |
| `OLLAMA_NUM_PARALLEL` | `1` | Loki est mono-utilisateur ; chaque slot parallèle multiplie la VRAM du cache KV. |
| `OLLAMA_FLASH_ATTENTION` | `1` | Attention plus rapide et plus sobre en VRAM. |
| `OLLAMA_KV_CACHE_TYPE` | `q8_0` | Cache KV quantifié : ~moitié de VRAM en moins, marge pour un 12B sur 12 Go. |
Exemple pour un Ollama en Docker (service `ollama` du compose) :
```yaml
ollama:
image: ollama/ollama
environment:
- OLLAMA_KEEP_ALIVE=30m
- OLLAMA_MAX_LOADED_MODELS=2
- OLLAMA_NUM_PARALLEL=1
- OLLAMA_FLASH_ATTENTION=1
- OLLAMA_KV_CACHE_TYPE=q8_0
``` ```
Côté Loki, tout est déjà optimisé : pool HTTP keep-alive partagé vers Ollama, Aucune compilation de llama.cpp n'a lieu : le moteur est maintenu et
options runner identiques sur tous les appels (pas de rechargement du modèle en précompilé par l'équipe amont (toutes architectures GPU courantes).
plein message), `keep_alive` systématique (chat **et** embeddings), préparation
du contexte (RAG + plan) en parallèle après l'ouverture du flux, et caches
courts (`/api/tags`, stats GPU).
## Modes d'exécution (Plan / Build / Yolo) ## Mettre à jour llama.cpp sans reconstruire l'image
Un sélecteur dans le composer contrôle le niveau d'autonomie de l'agent : llama.cpp publie plusieurs versions par jour ; l'image de Loki, elle, ne se
- **Plan** 🔍 — lecture seule (`read_file`, `list_dir`, `grep_search`) : l'agent reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à la date
analyse et propose sans jamais modifier de fichier ni exécuter de commande. du dernier build, et le rattraper imposait un rebuild complet (2,6 Go) pour un
- **Build** 🔨 — normal : écrit les fichiers, `run_shell` demande confirmation. composant de 170 Mo.
- **Yolo** ⚡ — autonomie maximale : approuve tout, y compris le shell.
## Panneau Git & Diff **Réglages → Moteur** affiche désormais la version de llama.cpp qui tourne
(`b10450`, avec son commit) et deux boutons :
Le workspace étant un dépôt git (chaque action de l'agent = un commit), l'onglet - **vérifier la version** — interroge le registre et dit s'il existe plus récent ;
**Git** du panneau de droite montre l'historique des commits, le **diff coloré** - **mettre à jour le moteur** — télécharge `llama-server` et ses bibliothèques
de chacun, et un bouton **↶ Annuler** (revert) pour défaire une modification en depuis l'image officielle, `ghcr.io/ggml-org/llama.cpp:server-cuda`.
un clic.
## Projets (répertoires de travail) Ce qui est téléchargé n'est **pas l'image** : un manifeste OCI liste ses couches,
et seules celles qui portent `/app` sont récupérées (~170 Mo). Le runtime CUDA
(2 Go) et la base système sont déjà dans l'image de Loki. Le moteur atterrit dans
`/data/engine/<version>/`, donc sur le volume de données : il survit à un
`docker compose pull`.
Chaque session peut travailler dans un **projet** : un sous-dossier du La variante est déduite du moteur en place (CUDA, Vulkan, SYCL, MUSA ou CPU) :
workspace choisi via le chip 📁 du composer (« + Nouveau projet » pour en une installation Vulkan ne se verra jamais proposer une image CUDA.
créer un). L'agent, le shell, le moteur code et l'arborescence sont confinés
au projet ; chaque projet a son propre dépôt git (historique et revert
indépendants). Session sans projet = racine du workspace.
## Intelligence augmentée **Le garde-fou.** La mise à jour apporte llama.cpp, pas le runtime CUDA, qui
reste celui de l'image. Un llama.cpp compilé pour un CUDA plus récent ne
chargerait pas son backend GPU ici — et le symptôme serait silencieux : tout
fonctionne, mais sur le processeur. Le nouveau moteur est donc lancé à blanc
avant toute bascule ; s'il ne démarre pas, ou s'il ne voit plus aucune carte
alors que le moteur courant en voyait, la mise à jour est refusée et le moteur
courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intact :
**revenir au moteur de l'image** y ramène en un clic, sans réseau.
- **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
affichées dans le fil ; l'agent (ou le moteur code) suit le plan. solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
- **Auto-critique « Qualité + »** (Configuration → Intelligence) : la réponse
est relue et révisée avant d'être finalisée.
- **Mémoire long-terme (RAG)** : chaque échange est vectorisé (`/api/embed`)
et les souvenirs pertinents des anciennes sessions sont réinjectés en
contexte. Nécessite un modèle d'embedding installé (ex.
`ollama pull nomic-embed-text`) — sinon désactivé silencieusement.
- **Vérification HTML** : liens locaux cassés et balises déséquilibrées sont
détectés après chaque génération ; le moteur code fait une passe
d'auto-correction.
- **Benchmark intégré** (Configuration → Benchmark) : 5 mini-épreuves notées
/100 (appel d'outil, code exécutable, consignes, extraction JSON, format)
pour comparer objectivement tes modèles installés.
## Serveurs MCP (outils professionnels) Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
Configuration → **Serveurs MCP** : catalogue préconfiguré, tout désactivé par ## Licence
défaut (un serveur inactif ne coûte rien — aucun process, aucun outil dans le
prompt du modèle).
| Serveur | Apport | MIT — © les contributeurs d'AJEAN (« Jean contributors ») pour le code amont,
| ------- | ------ | voir [`LICENSE`](LICENSE) et [`NOTICE.md`](NOTICE.md).
| Playwright | l'agent pilote un vrai navigateur : teste ses pages, lit la console |
| Context7 | documentation à jour de n'importe quelle librairie |
| Fetch | lecture propre d'URL (markdown) |
| SearxNG | vraie recherche web (URL d'instance requise) |
| Personnalisé | n'importe quel serveur MCP (commande stdio ou URL) |
Chaque carte a un bouton **Tester** (connexion d'essai + liste des outils
découverts). Un serveur en panne n'interrompt jamais le chat : notice dans le
fil, nouvelle tentative au message suivant.
## Skills automatiques
Cinq méthodes expertes (débogage systématique, création web, refactor sûr,
analyse de données, rédaction structurée) sont injectées automatiquement selon
la tâche détectée — sélection lexicale instantanée, une seule à la fois, badge
« 📘 Méthode : … » dans le fil. Désactivable dans Configuration → Intelligence.
## Tirer le meilleur des petits modèles
Loki est conçu pour qu'un modèle local modeste se comporte comme un bon agent :
- **Mémoire compressée** : au-delà d'un seuil, les anciens tours sont résumés
en arrière-plan et le modèle ne reçoit que « invite + résumé + 10 derniers
messages ». Contexte court = modèle concentré, et qui reste sur le GPU.
- **Outils chirurgicaux** : `edit_file` (recherche/remplacement exact — pas de
réécriture intégrale ratée), `grep_search` (trouver avant de modifier), et
`write_file` par morceaux (overwrite/append).
- **Auto-vérification** : après chaque écriture, la syntaxe (`.py`, `.json`)
est contrôlée ; l'erreur est renvoyée immédiatement au modèle, qui se
corrige dans le même tour.
- **Bon modèle au bon poste** : les tâches de code sont confiées au meilleur
modèle code installé (`qwen-coder`, `deepseek-coder`…), automatiquement
(config `code_model: auto`), même si tu discutes avec un généraliste.
- **Récupération d'appels d'outils malformés** : arguments réparés ou
redemandés, modèles sans function-calling détectés et gérés.
## Moteur code (façon Claude Code) — routage automatique
Loki embarque [Aider](https://aider.chat) (Apache-2.0, version figée) comme
**moteur code** : édition multi-fichiers fiable (formats diff/search-replace,
efficaces même avec de petits modèles), repo map, et **commits git
automatiques** dans le workspace.
**C'est invisible** : un routeur classe chaque message.
- Tâche de code détectée (heuristique lexicale instantanée) → le message part
au **moteur code** ; le fil affiche la carte `code_task`, les fichiers
modifiés et le commit.
- Sinon → **boucle agent** classique ; et l'agent peut lui-même déléguer au
moteur via l'outil `code_task` quand il juge qu'il faut coder.
Désactivable dans Configuration → Outils (`code_task`). Le workspace est
auto-initialisé en dépôt git : chaque modification de code = un commit
(historique et rollback via `git log` / `git revert` dans le workspace).
## Profil GPU fourni
Loki est préconfiguré pour une **RTX 3060 12 Go** avec `gemma4:12b` en Q4 :
contexte 8192, sortie 4096 jetons, batch 256, GPU principal 0 et les 49 couches
du modèle sur le GPU. Les paramètres de génération et de cache/contexte sont
enregistrés séparément pour chaque modèle.
La quantification du cache KV reste globale dans Ollama. Pour économiser environ
la moitié de sa VRAM, démarre Ollama avec `OLLAMA_FLASH_ATTENTION=1` et
`OLLAMA_KV_CACHE_TYPE=q8_0`. Un redémarrage d'Ollama est requis. Le conteneur
doit également exposer le GPU (`--gpus=all`) ; Loki ne peut pas contourner une
configuration Docker sans accès CUDA.
## Sécurité
- **Confinement** : toutes les opérations fichier (`read_file`, `write_file`,
`list_dir`) et `run_shell` sont strictement confinées au `WORKSPACE_DIR`. Toute
tentative de sortie (`../`, chemin absolu) est rejetée.
- **`run_shell`** est désactivé par défaut. Une fois activé, chaque commande
proposée par l'agent demande une **validation explicite** dans l'interface
(option *confirm_shell*, activée par défaut) avant exécution.
- Le conteneur tourne en **utilisateur non-root** et expose un **HEALTHCHECK**.
- Loki est conçu pour un usage **local** : n'expose pas le port publiquement sans
ajouter ta propre couche d'authentification.
## Feuille de route
- [x] **Phase 1** — Socle + design system fidèle au thème, layout 3 panneaux
- [x] **Phase 2** — Connexion Ollama : statut, liste des modèles, pull avec progression, sélecteur
- [x] **Phase 3** — Chat streaming (SSE) + persistance des sessions (SQLite)
- [x] **Phase 4** — Boucle agentique & outils fichiers (read/write/list), confinés au workspace, rendu des appels d'outils dans le fil
- [x] **Phase 5** — Aperçu HTML live + onglets Code/Logs + arborescence du workspace
- [x] **Phase 6** — Configuration complète (génération, toggles d'outils, invite système)
- [x] **Phase 7** — Outils avancés : `web_search` (DuckDuckGo/SearxNG) et `run_shell` avec validation utilisateur
- [x] **Phase 8** — Durcissement & documentation Docker
## Structure
```
backend/ FastAPI : routes Ollama, client httpx, config
frontend/ React : design system (tailwind.config), panneaux, store Zustand
workspace/ fichiers créés par l'agent (monté en volume)
data/ base SQLite (monté en volume)
```
+53
View File
@@ -0,0 +1,53 @@
# Loki 0.12.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.
## Le sélecteur de l'en-tête charge vraiment un modèle
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.
## Dictée vocale
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
## Cartes raisonnement/outils : taille fixe, défilement qui suit
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
génération — remonter lire le début est respecté. Les cartes d'outil suivent
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
texte complet est posé à la fin) : l'affichage ne se fige plus.
## Mesures
- La carte du temps total affiche la **vitesse moyenne de la conversation**
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
seulement les octets lus).
## Interface
- Discussions triées par date de **création** (récentes en tête) : une
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
Performance.
## Rappel 0.11.0
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
l'interface ; tâches planifiées reprises de l'amont.
## Mise à jour
```
docker compose pull && docker compose up -d
```
View File
Whitespace-only changes.
-517
View File
@@ -1,517 +0,0 @@
"""Boucle agentique : tool-calling itératif au-dessus d'Ollama.
L'agent dialogue avec le modèle ; quand celui-ci demande un outil, on l'exécute,
on réinjecte le résultat, et on reboucle jusqu'à une réponse finale (ou la
limite d'itérations). La fonction est un générateur asynchrone d'événements
relayés tels quels au client via SSE.
Événements émis :
token {content} — fragment de texte de l'agent
tool_call {name, args} — début d'exécution d'un outil
tool_result {name, args, summary, status}
final {content, tools} — réponse complète + récap des outils
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import AsyncIterator
import httpx
from . import coder
from .ollama_client import OllamaError, ollama
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
MAX_ITERATIONS = 6
MAX_TOOL_REPAIR_ATTEMPTS = 2
# Coupe-circuit de réflexion : au-delà de cette taille de pensée SANS aucun
# contenu ni appel d'outil, on interrompt la génération en cours au lieu
# d'attendre l'épuisement du budget num_predict (minutes sur un gros modèle).
_MAX_THINKING_CHARS = 12_000
# Élagage : au-delà de cette taille, un résultat d'outil des itérations
# passées est tronqué. Les gros payloads (MCP 8 Ko, shell 4 Ko) saturaient le
# contexte en un seul tour long — Ollama tronquait alors silencieusement le
# DÉBUT de la conversation, faisant « oublier » la consigne au modèle.
_PRUNE_KEEP_CHARS = 350
def _prune_old_tool_results(convo: list[dict], before_index: int) -> None:
"""Compacte les résultats d'outils déjà consommés par le modèle.
Seuls les messages ``tool`` antérieurs à ``before_index`` (donc traités
lors d'une itération précédente) sont tronqués ; le dernier lot reste
intact, c'est celui auquel le modèle répond.
"""
for msg in convo[:before_index]:
content = msg.get("content", "")
if msg.get("role") == "tool" and len(content) > _PRUNE_KEEP_CHARS:
msg["content"] = (
content[:_PRUNE_KEEP_CHARS]
+ "… [résultat archivé — déjà traité, ne pas redemander]"
)
def _tools_not_supported(exc: OllamaError) -> bool:
"""Détecte un modèle incapable de function calling.
Deux cas :
- Ollama refuse explicitement (« does not support tools ») ;
- Ollama n'arrive pas à dériver un parseur d'appels d'outils du template
du modèle (« Unable to generate parser for this template ») — fréquent
sur des modèles exotiques dont le template Jinja lève une exception.
Dans les deux cas, on retombe sur une conversation simple, sans outils.
"""
message = str(exc).lower()
return (
"does not support tools" in message
or "does not support tool" in message
or "unable to generate parser for this template" in message
or "automatic parser generation failed" in message
)
def _thinking_not_supported(exc: OllamaError) -> bool:
"""Le modèle (ou son template) refuse le paramètre ``think``."""
message = str(exc).lower()
return "does not support thinking" in message or "thinking is not supported" in message
def _invalid_tool_arguments(exc: OllamaError) -> bool:
message = str(exc).lower()
return (
"invalid tool call arguments" in message
or "unexpected end of json" in message
or "failed to parse tool" in message
)
def _parse_args(raw) -> dict:
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
return {}
# Marqueur d'avancement du plan émis par le modèle (« ✅ Étape 2 terminée »).
# Tolérant : coche/croix optionnelle, mot « étape » optionnel, numéro requis.
_STEP_DONE = re.compile(
r"(?:✅|✔|☑|\[x\])\s*(?:étape|etape|step)?\s*(\d{1,2})"
r"|(?:étape|etape|step)\s*(\d{1,2})\s*(?:terminée|terminee|faite|ok|✅|✔)",
re.I,
)
def _scan_plan_done(text: str, plan_len: int, already: set[int]) -> list[int]:
"""Indices (0-based) de nouvelles étapes annoncées terminées dans ``text``."""
fresh: list[int] = []
for m in _STEP_DONE.finditer(text):
num = m.group(1) or m.group(2)
idx = int(num) - 1
if 0 <= idx < plan_len and idx not in already:
already.add(idx)
fresh.append(idx)
return fresh
async def run_agent(
model: str,
convo: list[dict],
*,
options: dict | None = None,
enabled_tools: list[str] | None = None,
confirm_shell: bool = True,
think: bool = True,
keep_alive: str | None = None,
mcp_tools: list[dict] | None = None,
plan: list[str] | None = None,
) -> AsyncIterator[dict]:
# enabled_tools=None -> tous les outils ; liste vide -> aucun outil.
if enabled_tools is None:
tools = list(TOOL_DEFINITIONS)
elif enabled_tools:
tools = [
t for t in TOOL_DEFINITIONS if t["function"]["name"] in enabled_tools
]
else:
tools = []
# Outils MCP des serveurs activés (déjà résolus par l'appelant).
tools.extend(mcp_tools or [])
if not tools:
tools = None
collected: list[dict] = []
text_parts: list[str] = []
thinking_parts: list[str] = []
active_tools = tools
tool_fallback_used = False
tool_repair_attempts = 0
request_options = dict(options or {})
# On n'envoie ``think`` que pour le DÉSACTIVER (False) ; laissé à None, le
# modèle garde son comportement par défaut. Repli si le modèle le refuse.
request_think: bool | None = None if think else False
# Mode réflexion : la pensée consomme le budget num_predict. Sans marge,
# le modèle « pense » tout son quota et s'arrête sans agir ni répondre.
if request_think is None and think:
request_options["num_predict"] = max(
int(request_options.get("num_predict") or 0), 6144
)
# Compteur d'itérations où le modèle n'a produit QUE du raisonnement.
thinking_only_strikes = 0
# Suivi de l'avancement du plan : étapes déjà annoncées terminées.
plan_len = len(plan or [])
plan_done: set[int] = set()
# Métriques cumulées sur tous les appels Ollama du tour agentique : Ollama
# les renvoie dans le chunk final (done=true) de chaque génération.
stats = {"eval_count": 0, "eval_duration": 0, "prompt_eval_count": 0}
def _accumulate(chunk: dict) -> None:
stats["eval_count"] += chunk.get("eval_count") or 0
stats["eval_duration"] += chunk.get("eval_duration") or 0
stats["prompt_eval_count"] += chunk.get("prompt_eval_count") or 0
# Index du début du dernier lot de résultats d'outils (à préserver).
last_batch_start = 0
try:
for _ in range(MAX_ITERATIONS):
content_buf = ""
thinking_buf = ""
thinking_status_sent = False
tool_calls: list[dict] = []
# Compacte les résultats d'outils des itérations antérieures :
# garde le contexte court, la consigne système jamais tronquée.
if last_batch_start:
_prune_old_tool_results(convo, last_batch_start)
# Un modèle peut savoir discuter sans supporter les outils. Ollama
# refuse alors la requête entière : on retente une fois en chat simple.
while True:
try:
async for chunk in ollama.chat(
model,
convo,
tools=active_tools,
options=request_options,
think=request_think,
keep_alive=keep_alive,
stream=True,
):
msg = chunk.get("message", {})
token = msg.get("content", "")
thinking = msg.get("thinking", "")
if thinking:
thinking_buf += thinking
# Diffuse le raisonnement en direct pour l'afficher
# dans le panneau repliable du chat.
yield {"type": "thinking", "content": thinking}
if not thinking_status_sent:
thinking_status_sent = True
yield {"type": "status", "message": "Réflexion…"}
# Pensée interminable sans production : on coupe la
# génération maintenant — la relance (plus bas)
# remet le modèle au travail immédiatement.
if (
len(thinking_buf) > _MAX_THINKING_CHARS
and not content_buf
and not tool_calls
):
break
if token:
content_buf += token
yield {"type": "token", "content": token}
# Coche les étapes du plan annoncées terminées, en
# direct. Scan borné : uniquement quand le token
# porte un marqueur plausible.
if plan_len and any(
c in token for c in ("✅", "✔", "☑", "tape", "step", "]")
):
for idx in _scan_plan_done(content_buf, plan_len, plan_done):
yield {"type": "plan_step", "index": idx, "status": "done"}
if msg.get("tool_calls"):
tool_calls.extend(msg["tool_calls"])
if chunk.get("done"):
_accumulate(chunk)
break
break
except OllamaError as exc:
if (
active_tools
and tool_repair_attempts < MAX_TOOL_REPAIR_ATTEMPTS
and not content_buf
and not tool_calls
and _invalid_tool_arguments(exc)
):
tool_repair_attempts += 1
request_options["num_predict"] = max(
int(request_options.get("num_predict", 0)), 4096
)
thinking_buf = ""
# NB : on réinjecte ce rappel en `user`, pas en `system`.
# Beaucoup de templates (Gemma, Mistral…) lèvent
# « System message must be at the beginning » dès qu'un
# message system apparaît ailleurs qu'en tête, ce qui
# ferait échouer toute la requête en 400.
convo.append(
{
"role": "user",
"content": (
"L'appel d'outil précédent contenait un JSON "
"tronqué. Réessaie immédiatement avec des arguments "
"JSON valides. Pour un fichier long, utilise write_file "
"en plusieurs appels : overwrite puis append, avec des "
"morceaux courts et complets."
),
}
)
yield {
"type": "notice",
"message": (
"Appel d'outil tronqué : nouvelle tentative "
f"{tool_repair_attempts}/{MAX_TOOL_REPAIR_ATTEMPTS}."
),
}
continue
if (
active_tools
and not tool_fallback_used
and not content_buf
and not tool_calls
and _tools_not_supported(exc)
):
active_tools = None
tool_fallback_used = True
yield {
"type": "notice",
"message": (
"Ce modèle ne supporte pas les outils ; "
"réponse en mode conversation simple."
),
}
continue
if request_think is not None and _thinking_not_supported(exc):
# Le modèle n'accepte pas qu'on désactive sa réflexion :
# on retire le paramètre et on relance.
request_think = None
continue
raise
# Itération « réflexion seule » : ni contenu, ni appel d'outil —
# le modèle a brûlé sa génération à penser. Sans relance, la
# boucle s'arrêtait là et la tâche restait inachevée.
if thinking_buf and not content_buf.strip() and not tool_calls:
thinking_parts.append(thinking_buf)
thinking_only_strikes += 1
if thinking_only_strikes == 1:
convo.append({
"role": "user",
"content": (
"Tu n'as produit que du raisonnement, sans réponse "
"ni action. Continue la tâche MAINTENANT : appelle "
"l'outil suivant ou donne ta réponse finale, sans "
"réfléchir davantage."
),
})
yield {"type": "status", "message": "Relance après réflexion…"}
continue
# Deuxième fois : la réflexion est coupée pour finir la tâche.
request_think = False
yield {
"type": "notice",
"message": (
"Réflexion désactivée pour ce tour : le modèle "
"n'avançait plus."
),
}
continue
assistant_turn: dict = {"role": "assistant", "content": content_buf}
if thinking_buf:
# Gardée pour l'affichage (panneau repliable), mais JAMAIS
# renvoyée au modèle : la re-soumettre gonflait le contexte à
# chaque itération des longues tâches.
thinking_parts.append(thinking_buf)
if tool_calls:
assistant_turn["tool_calls"] = tool_calls
convo.append(assistant_turn)
if content_buf.strip():
text_parts.append(content_buf.strip())
if not tool_calls:
break
# Les résultats du lot qui suit commencent ici : ils restent
# intacts au prochain tour, les précédents seront compactés.
last_batch_start = len(convo)
# Exécution des outils demandés, puis réinjection des résultats.
awaiting_confirmation = False
for tc in tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "")
args = _parse_args(fn.get("arguments"))
yield {"type": "tool_call", "name": name, "args": args}
# run_shell est sensible : on demande validation au lieu d'exécuter.
if name == "run_shell" and confirm_shell:
command = args.get("command", "")
record = {
"name": name,
"args": args,
"summary": "validation requise",
"status": "pending",
}
collected.append(record)
yield {"type": "tool_confirm", "name": name, "command": command}
convo.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(
{
"ok": False,
"status": "pending",
"message": "Commande en attente de validation "
"de l'utilisateur. N'exécute rien d'autre.",
},
ensure_ascii=False,
),
}
)
awaiting_confirmation = True
continue
# code_task : délégué au moteur code (Aider), long -> thread.
if name == "code_task":
from .tools import active_root
code_model = await coder.pick_code_model(model)
result = await asyncio.to_thread(
coder.run_code_task,
args.get("instruction", ""),
code_model,
args.get("files") or [],
active_root(),
)
summary = result.get("summary", "terminé")
status = "ok" if result.get("ok") else "error"
record = {"name": name, "args": {"instruction": args.get("instruction", "")},
"summary": summary, "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append({
"role": "tool",
"tool_name": name,
"content": json.dumps(
{k: result.get(k) for k in ("ok", "summary", "files", "text")},
ensure_ascii=False,
),
})
continue
# Outils MCP : dispatch asynchrone vers le serveur concerné.
if name.startswith("mcp_"):
from .mcp_client import manager as mcp_manager
mcp_res = await mcp_manager.call_tool(name, args)
status = "ok" if mcp_res["ok"] else "error"
record = {"name": name, "args": args,
"summary": mcp_res["summary"], "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append({
"role": "tool",
"tool_name": name,
"content": json.dumps(
{"ok": mcp_res["ok"], "content": mcp_res["content"]},
ensure_ascii=False,
),
})
continue
try:
result = run_tool(name, args)
summary = result.get("summary", "terminé")
status = result.get("_status", "ok")
except ToolError as exc:
result = {"ok": False, "error": str(exc)}
summary = str(exc)
status = "error"
record = {"name": name, "args": args, "summary": summary, "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(result, ensure_ascii=False),
}
)
# Une commande shell attend une validation : on interrompt la boucle.
if awaiting_confirmation:
# Laisse le modèle conclure son tour (message d'attente).
final_chunk = ""
async for chunk in ollama.chat(
model, convo, options=request_options,
think=request_think, keep_alive=keep_alive, stream=True
):
tok = chunk.get("message", {}).get("content", "")
if tok:
final_chunk += tok
yield {"type": "token", "content": tok}
if chunk.get("done"):
_accumulate(chunk)
break
if final_chunk.strip():
text_parts.append(final_chunk.strip())
break
except OllamaError as exc:
# Échec signalé par Ollama (HTTP ou ligne d'erreur dans le flux) :
# souvent un débordement mémoire / contexte trop grand. On l'expose.
yield {"type": "error", "message": f"Ollama : {exc}"}
return
except (httpx.HTTPError, OSError) as exc:
yield {
"type": "error",
"message": f"Impossible de joindre Ollama ({ollama.host}) : {exc}",
}
return
final_content = "\n\n".join(text_parts).strip()
if not final_content and not collected:
yield {
"type": "error",
"message": (
"Le modèle a terminé sans renvoyer de texte (il n'a produit que "
"du raisonnement). Désactive « Mode réflexion » dans les Réglages, "
"ou essaie un modèle de chat plus récent."
),
}
return
eval_secs = stats["eval_duration"] / 1e9
final_stats = {
"eval_count": stats["eval_count"],
"prompt_eval_count": stats["prompt_eval_count"],
"tokens_per_sec": (
round(stats["eval_count"] / eval_secs, 1) if eval_secs > 0 else None
),
}
yield {
"type": "final",
"content": final_content,
"tools": collected,
"stats": final_stats,
"thinking": "\n\n".join(thinking_parts).strip(),
}
-309
View File
@@ -1,309 +0,0 @@
"""Profil de configuration de l'agent : invite système, génération, outils.
Persisté en base sous la clé `agent`. Fournit les valeurs par défaut et la
fusion avec ce qui est stocké, pour rester robuste aux montées de version.
"""
from __future__ import annotations
import re
from . import db
CONFIG_KEY = "agent"
MODEL_PROFILES_KEY = "model_profiles"
PROFILE_STATE_KEY = "model_profiles_state"
PROFILE_VERSION = 7
DEFAULT_SYSTEM_PROMPT = (
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
"d'outils pour lire, écrire et lister des fichiers dans le workspace, et "
"d'un moteur code (code_task) pour toute création ou modification de code "
"multi-fichiers : privilégie code_task pour les tâches de programmation. "
"Utilise les outils pour accomplir les tâches concrètement, puis réponds "
"de façon concise en français. Pour MODIFIER un fichier existant, utilise "
"edit_file (search/replace) plutôt que de tout réécrire : lis d'abord le "
"fichier avec read_file, puis copie dans `search` l'extrait EXACT à changer "
"(quelques lignes suffisent, l'indentation est tolérée). N'emploie "
"write_file en overwrite QUE pour créer un nouveau fichier ou en cas de "
"refonte complète. Quand tu appelles write_file, fournis toujours `path` et "
"`content` ; pour un fichier long, appelle write_file en plusieurs morceaux "
"(overwrite puis append) afin de toujours produire un JSON valide. Tu ne "
"peux écrire QUE dans le workspace : jamais de chemin absolu ni de `../` "
"qui en sortent. Après avoir écrit un fichier, propose un aperçu. "
"Formate TOUJOURS tes réponses en Markdown : titres, listes, gras pour les "
"points clés, tableaux si pertinent, et surtout des blocs de code avec le "
"langage indiqué (```python, ```html…) pour tout extrait de code ou commande."
)
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
# par défaut, conformément à la maquette.
AVAILABLE_TOOLS = [
"read_file", "write_file", "edit_file", "list_dir", "grep_search",
"run_check", "code_task", "web_search", "run_shell",
]
SENSITIVE_TOOLS = {"run_shell"}
DEFAULT_TOOL_STATE = {
"read_file": True,
"write_file": True,
"edit_file": True,
"list_dir": True,
"grep_search": True,
"run_check": True,
"code_task": True,
"web_search": False,
"run_shell": False,
}
GENERATION_FIELDS = {
"temperature",
"top_p",
"top_k",
"max_tokens",
"num_ctx",
"num_gpu",
"num_batch",
}
PROFILE_FIELDS = {
"system_prompt",
"tools",
"confirm_shell",
"think",
"code_model",
"plan_mode",
"self_review",
"rag_enabled",
"embed_model",
"memory_mode",
"skills_enabled",
"ponytail",
"keep_alive",
*GENERATION_FIELDS,
}
DEFAULT_GENERATION: dict = {
"temperature": 0.7,
"top_p": 0.9,
"top_k": 40,
"max_tokens": 2048,
# 16k : les tâches longues (recherche, multi-fichiers) saturaient 4-8k en
# un seul tour et Ollama tronquait la consigne. Le cache KV d'un 16k reste
# raisonnable (~1-3 Go selon modèle) ; réduis num_ctx dans Configuration
# si la VRAM déborde, ou active OLLAMA_KV_CACHE_TYPE=q8_0 côté Ollama.
"num_ctx": 16384,
"num_gpu": -1,
"num_batch": 256,
}
RTX_3060_GEMMA4_PROFILE: dict = {
**DEFAULT_GENERATION,
"max_tokens": 4096,
"num_ctx": 16384,
# num_gpu = -1 : laisse Ollama placer le plus de couches possible sur le GPU
# (auto-fit, comme `ollama run`). Forcer un nombre de couches qui ne tient pas
# en VRAM fait basculer toute l'inférence sur le CPU.
"num_gpu": -1,
}
DEFAULT_CONFIG: dict = {
"system_prompt": DEFAULT_SYSTEM_PROMPT,
**DEFAULT_GENERATION,
"tools": dict(DEFAULT_TOOL_STATE),
# Demander une validation utilisateur avant toute commande shell.
"confirm_shell": True,
# Mode réflexion des modèles « thinking ». Désactiver (False) évite qu'un
# modèle ne renvoie que du raisonnement sans réponse finale.
"think": True,
# Modèle utilisé par le moteur code : "auto" = meilleur modèle code installé
# (qwen-coder, deepseek-coder…), sinon le modèle de chat courant.
"code_model": "auto",
# Plan-puis-exécute : décompose les demandes complexes en étapes.
"plan_mode": True,
# Auto-critique : une passe de relecture/révision avant la réponse finale.
"self_review": False,
# Mémoire long-terme (RAG) ENTRE sessions, via un modèle d'embedding.
# Désactivée par défaut : chaque discussion ne se souvient que d'elle-même
# (résumé + messages récents). Sinon une ancienne demande sans rapport (ex.
# « appli sport ») ressurgit dans une nouvelle discussion (ex. « jeu
# d'échecs ») et embrouille les petits modèles. Réactivable dans Réglages.
"rag_enabled": False,
"embed_model": "auto",
# Mémoire en notes Markdown écrites par l'agent (voir memory_notes.py).
# "ondemand" : les outils existent, mais rien n'est injecté sans que le
# modèle le demande — aucun risque de voir ressurgir une demande sans
# rapport, contrairement à l'ancien RAG vectoriel.
"memory_mode": "ondemand",
# Skills : méthodes expertes injectées automatiquement selon la tâche.
"skills_enabled": True,
# Ponytail : méthode « code minimal » (anti sur-ingénierie) injectée pour
# toute tâche de code. Voir github.com/DietrichGebert/ponytail.
"ponytail": True,
# Durée de maintien du modèle en VRAM (préchargement). "0" = décharge
# aussitôt, "-1" = jamais, "30m" = 30 minutes.
"keep_alive": "30m",
}
def _default_generation(model: str | None) -> dict:
if model and model.split(":", 1)[0].lower() == "gemma4":
return dict(RTX_3060_GEMMA4_PROFILE)
return dict(DEFAULT_GENERATION)
def _migrate_profiles() -> None:
state = db.get_config_value(PROFILE_STATE_KEY) or {}
if state.get("version", 0) >= PROFILE_VERSION:
return
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
gemma_profile = {
**RTX_3060_GEMMA4_PROFILE,
**profiles.get("gemma4:12b", {}),
}
if gemma_profile.get("max_tokens", 0) <= 2048:
gemma_profile["max_tokens"] = 4096
profiles["gemma4:12b"] = gemma_profile
# v4 : un ancien profil pouvait forcer num_gpu sur un nombre de couches codé
# en dur (ex. 49), ce qui basculait l'inférence sur le CPU quand ça ne tenait
# pas en VRAM. On repasse en auto (-1) pour laisser Ollama placer les couches.
for prof in profiles.values():
if prof.get("num_gpu", -1) is not None and prof.get("num_gpu", -1) > 0:
prof["num_gpu"] = -1
# v6 : contexte 16k par défaut. On ne touche qu'aux profils restés sur un
# ancien défaut (4096/8192) — une valeur personnalisée est respectée.
for prof in profiles.values():
if prof.get("num_ctx") in (4096, 8192):
prof["num_ctx"] = 16384
db.set_config_value(MODEL_PROFILES_KEY, profiles)
# v7 : la mémoire inter-sessions (RAG) faisait ressurgir d'anciennes
# demandes sans rapport dans une nouvelle discussion. On la désactive une
# fois sur les installs existantes ; réactivable manuellement dans Réglages.
stored = db.get_config_value(CONFIG_KEY)
if stored and stored.get("rag_enabled"):
stored["rag_enabled"] = False
db.set_config_value(CONFIG_KEY, stored)
db.set_config_value(PROFILE_STATE_KEY, {"version": PROFILE_VERSION})
def _clean_tools(value: dict | None, fallback: dict | None = None) -> dict:
fallback = fallback or DEFAULT_TOOL_STATE
return {
name: bool((value or {}).get(name, fallback.get(name, DEFAULT_TOOL_STATE[name])))
for name in AVAILABLE_TOOLS
}
def get_config(model: str | None = None) -> dict:
"""Configuration complète, avec surcharge sauvegardée par modèle."""
_migrate_profiles()
stored = db.get_config_value(CONFIG_KEY) or {}
cfg = {**DEFAULT_CONFIG, **stored}
cfg["tools"] = _clean_tools(stored.get("tools"))
if model:
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
profile = profiles.get(model, {})
cfg.update({**_default_generation(model), **profile})
cfg["tools"] = _clean_tools(profile.get("tools"), cfg["tools"])
return cfg
def save_config(patch: dict, model: str | None = None) -> dict:
"""Sauvegarde tous les réglages, globalement ou pour un modèle."""
clean = {k: v for k, v in patch.items() if v is not None}
cfg = {**get_config(model), **clean}
if "tools" in patch and patch["tools"]:
cfg["tools"] = _clean_tools(patch["tools"], cfg["tools"])
if model:
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
profiles[model] = {field: cfg[field] for field in PROFILE_FIELDS}
db.set_config_value(MODEL_PROFILES_KEY, profiles)
else:
db.set_config_value(CONFIG_KEY, {field: cfg[field] for field in PROFILE_FIELDS})
return get_config(model)
def runner_options(cfg: dict) -> dict:
"""Sous-ensemble d'options qui détermine l'identité du runner Ollama.
Ollama choisit son runner (processus de chargement du modèle) d'après
num_ctx / num_batch / num_gpu. TOUT appel au même modèle (plan, résumé,
agent…) doit envoyer ces mêmes valeurs, sinon Ollama recharge le modèle en
plein milieu d'un message — la cause principale des lenteurs observées.
"""
opts: dict = {"num_batch": cfg["num_batch"]}
# num_gpu n'est transmis que si l'utilisateur force explicitement un nombre
# de couches (≥ 0). En -1 (défaut), on laisse Ollama auto-ajuster l'offload
# GPU comme `ollama run` ; lui imposer une valeur peut le forcer sur le CPU.
if cfg.get("num_gpu", -1) >= 0:
opts["num_gpu"] = cfg["num_gpu"]
opts["main_gpu"] = 0
# num_ctx n'est envoyé que s'il est défini (> 0), sinon défaut du modèle.
if cfg.get("num_ctx"):
opts["num_ctx"] = cfg["num_ctx"]
return opts
def ollama_options(cfg: dict) -> dict:
"""Traduit la config en options de génération Ollama."""
return {
**runner_options(cfg),
"temperature": cfg["temperature"],
"top_p": cfg["top_p"],
"top_k": cfg["top_k"],
"num_predict": cfg["max_tokens"],
}
# ── Presets : jeux de réglages nommés ────────────────────────────────────
PRESETS_KEY = "config_presets"
_PRESET_NAME = re.compile(r"^[\w \-]{1,40}$")
def list_presets() -> list[str]:
return sorted((db.get_config_value(PRESETS_KEY) or {}).keys())
def save_preset(name: str, model: str | None = None) -> list[str]:
"""Fige la configuration courante sous ce nom (écrase si déjà pris)."""
name = (name or "").strip()
if not _PRESET_NAME.match(name):
raise ValueError("nom de preset invalide (lettres, chiffres, espaces, -)")
presets = db.get_config_value(PRESETS_KEY) or {}
cfg = get_config(model)
presets[name] = {field: cfg[field] for field in PROFILE_FIELDS if field in cfg}
db.set_config_value(PRESETS_KEY, presets)
return sorted(presets)
def apply_preset(name: str, model: str | None = None) -> dict | None:
"""Applique un preset ; None s'il n'existe pas."""
preset = (db.get_config_value(PRESETS_KEY) or {}).get((name or "").strip())
if preset is None:
return None
return save_config(dict(preset), model)
def delete_preset(name: str) -> list[str]:
presets = db.get_config_value(PRESETS_KEY) or {}
presets.pop((name or "").strip(), None)
db.set_config_value(PRESETS_KEY, presets)
return sorted(presets)
def enabled_tool_names(cfg: dict) -> list[str]:
"""Outils réellement proposables au modèle.
Un outil activé dans la config mais INUTILISABLE (dépendance absente) ne
doit pas apparaître dans le contexte : sinon le modèle l'appelle, échoue,
et gâche un tour. Mieux vaut qu'il ne sache pas qu'il existe et emploie
directement write_file / edit_file.
"""
names = [name for name, on in cfg["tools"].items() if on]
if "code_task" in names:
from . import coder
if not coder.available():
names.remove("code_task")
return names
-267
View File
@@ -1,267 +0,0 @@
"""Auto-réglage : exploite au mieux chaque modèle selon le GPU disponible.
Principe :
1. on détecte la VRAM du GPU (nvidia-smi, sinon rocm-smi, sinon override env) ;
2. on lit les métadonnées du modèle via Ollama (/api/show + /api/tags) :
contexte max, architecture (couches, têtes KV, dimension), taille sur disque ;
3. on calcule la fenêtre de contexte (num_ctx) la plus grande qui tient en VRAM,
via une estimation du cache KV, puis un nombre de jetons de sortie cohérent.
Tout est best-effort : si une info manque, on retombe sur des paliers prudents.
"""
from __future__ import annotations
import shutil
import subprocess
import httpx
from .config import settings
from .ollama_client import ollama
# Paliers de contexte « ronds » proposés (bornés par le contexte du modèle).
CTX_STEPS = [2048, 4096, 8192, 12288, 16384, 24576, 32768, 49152, 65536, 131072]
# Marges VRAM (Mo) : OS/driver + buffers de calcul d'Ollama.
VRAM_OVERHEAD_MB = 1024
VRAM_COMPUTE_BUFFER_MB = 768
# ── Détection GPU ────────────────────────────────────────────────────────
def detect_gpu() -> dict:
"""Renvoie {available, name, vram_total_mb, source}."""
# 1) Override explicite (env) — prioritaire, utile en conteneur sans GPU.
if settings.gpu_vram_mb > 0:
return {
"available": True,
"name": settings.gpu_name or "GPU (configuré)",
"vram_total_mb": settings.gpu_vram_mb,
"source": "env",
}
# 2) NVIDIA
if shutil.which("nvidia-smi"):
try:
out = subprocess.run(
["nvidia-smi",
"--query-gpu=name,memory.total",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5,
)
if out.returncode == 0 and out.stdout.strip():
line = out.stdout.strip().splitlines()[0]
name, total = [p.strip() for p in line.split(",")]
return {
"available": True,
"name": name,
"vram_total_mb": int(float(total)),
"source": "nvidia-smi",
}
except (OSError, ValueError, subprocess.SubprocessError):
pass
# 3) AMD (ROCm) — best-effort
if shutil.which("rocm-smi"):
try:
out = subprocess.run(
["rocm-smi", "--showmeminfo", "vram", "--csv"],
capture_output=True, text=True, timeout=5,
)
if out.returncode == 0:
# Cherche le plus grand entier (octets) -> Mo
nums = [int(x) for x in out.stdout.replace(",", " ").split()
if x.isdigit()]
if nums:
return {
"available": True,
"name": "GPU AMD",
"vram_total_mb": max(nums) // (1024 * 1024),
"source": "rocm-smi",
}
except (OSError, ValueError, subprocess.SubprocessError):
pass
return {"available": False, "name": "CPU", "vram_total_mb": 0, "source": "none"}
# ── Métadonnées modèle ───────────────────────────────────────────────────
async def model_profile(model: str) -> dict:
"""Extrait contexte max, archi (couches/têtes/dim), taille disque (Mo)."""
profile = {
"context_length": None,
"block_count": None,
"head_count": None,
"head_count_kv": None,
"embedding_length": None,
"size_mb": None,
"parameter_size": None,
"quantization": None,
}
try:
show = await ollama.show(model)
except (httpx.HTTPError, OSError):
return profile
info = show.get("model_info", {}) or {}
arch = info.get("general.architecture", "")
def pick(*suffixes):
for s in suffixes:
key = f"{arch}.{s}" if arch else s
if key in info:
return info[key]
for k, v in info.items():
if k.endswith(s):
return v
return None
profile["context_length"] = pick("context_length")
profile["block_count"] = pick("block_count")
profile["head_count"] = pick("attention.head_count")
profile["head_count_kv"] = pick("attention.head_count_kv")
profile["embedding_length"] = pick("embedding_length")
details = show.get("details", {}) or {}
profile["parameter_size"] = details.get("parameter_size")
profile["quantization"] = details.get("quantization_level")
# Taille sur disque via /api/tags
try:
for m in await ollama.list_models():
if m.get("name") == model:
profile["size_mb"] = int(m.get("size", 0)) // (1024 * 1024)
break
except (httpx.HTTPError, OSError):
pass
return profile
def _kv_bytes_per_token(p: dict) -> int | None:
"""Estimation des octets de cache KV par jeton (KV en f16)."""
blocks = p.get("block_count")
kv_heads = p.get("head_count_kv")
heads = p.get("head_count")
emb = p.get("embedding_length")
if not all((blocks, kv_heads, heads, emb)):
return None
head_dim = emb / heads
# 2 (clé+valeur) * couches * têtes_kv * dim_tête * 2 octets (f16)
return int(2 * blocks * kv_heads * head_dim * 2)
def _round_ctx(candidate: int, ctx_max: int | None) -> int:
cap = ctx_max or CTX_STEPS[-1]
best = CTX_STEPS[0]
for step in CTX_STEPS:
if step <= candidate and step <= cap:
best = step
# Si le modèle plafonne bas, respecte son contexte max.
return min(best, cap)
# ── Recommandation ───────────────────────────────────────────────────────
async def recommend(model: str) -> dict:
gpu = detect_gpu()
prof = await model_profile(model)
ctx_max = prof.get("context_length")
rationale: list[str] = []
if not gpu["available"]:
# GPU non détecté CÔTÉ LOKI : Ollama tourne probablement sur une autre
# machine. On ne force surtout PAS num_ctx (0 = défaut du modèle), sinon
# Ollama chargerait une instance distincte qui déborderait sur le CPU.
num_ctx = 0
rationale.append(
"GPU non détecté côté Loki (Ollama distant ?) — contexte laissé au "
"défaut du modèle pour rester sur le GPU. Déclare GPU_VRAM_MB pour "
"un réglage précis."
)
else:
vram = gpu["vram_total_mb"]
model_mb = prof.get("size_mb") or _fallback_model_mb(prof)
budget = vram - model_mb - VRAM_OVERHEAD_MB - VRAM_COMPUTE_BUFFER_MB
kv_per_tok = _kv_bytes_per_token(prof)
if budget <= 256:
num_ctx = _round_ctx(2048, ctx_max)
rationale.append("VRAM insuffisante pour les poids — contexte minimal.")
elif kv_per_tok:
tokens_fit = int(budget * 1024 * 1024 / kv_per_tok)
num_ctx = _round_ctx(tokens_fit, ctx_max)
rationale.append(
f"{vram} Mo VRAM − {model_mb} Mo poids → budget KV "
f"{budget} Mo (~{kv_per_tok // 1024} Ko/jeton)."
)
else:
# Archi inconnue : paliers selon le budget VRAM restant.
num_ctx = _round_ctx(_tier_ctx(budget), ctx_max)
rationale.append("Archi modèle incomplète — estimation par paliers.")
# Jetons de sortie : moitié du contexte, borné. Si num_ctx=0 (auto), on
# garde une valeur raisonnable sans la réduire.
max_tokens = 2048 if num_ctx == 0 else max(512, min(num_ctx // 2, 8192))
return {
"gpu": gpu,
"model": model,
"model_profile": {
"context_length": ctx_max,
"parameter_size": prof.get("parameter_size"),
"quantization": prof.get("quantization"),
"size_mb": prof.get("size_mb"),
},
"recommended": {"num_ctx": num_ctx, "max_tokens": max_tokens},
"rationale": " ".join(rationale),
}
async def placement(model: str) -> dict:
"""Lit /api/ps : le modèle est-il chargé sur GPU, CPU, ou un mix ?"""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return {"loaded": False}
for m in loaded:
if m.get("name") == model or m.get("model") == model:
size = m.get("size", 0) or 0
size_vram = m.get("size_vram", 0) or 0
if size <= 0:
where = "inconnu"
elif size_vram >= size * 0.99:
where = "gpu"
elif size_vram <= size * 0.01:
where = "cpu"
else:
where = "mixte"
pct = int(size_vram / size * 100) if size else 0
return {"loaded": True, "where": where, "gpu_percent": pct,
"size_mb": size // (1024 * 1024)}
return {"loaded": False}
def _fallback_model_mb(prof: dict) -> int:
"""Estime la taille des poids si /api/tags n'a rien donné."""
ps = (prof.get("parameter_size") or "").upper().replace("B", "")
try:
billions = float(ps)
except ValueError:
billions = 8.0
# ~0.6 Go/milliard en Q4, approximation prudente.
return int(billions * 600)
def _tier_ctx(budget_mb: int) -> int:
if budget_mb >= 12000:
return 32768
if budget_mb >= 8000:
return 16384
if budget_mb >= 5000:
return 12288
if budget_mb >= 3000:
return 8192
if budget_mb >= 1500:
return 4096
return 2048
-222
View File
@@ -1,222 +0,0 @@
"""Benchmark intégré : évalue objectivement chaque modèle installé.
Cinq mini-épreuves (~30-60 s au total) qui mesurent ce qui compte pour Loki :
appel d'outil, code exécutable, respect des consignes, extraction JSON,
respect d'un format. Score /100, stocké en base et affiché dans l'UI.
"""
from __future__ import annotations
import asyncio
import json
import re
import subprocess
import sys
import tempfile
import time
from typing import AsyncIterator
import httpx
from . import db
from .ollama_client import OllamaError, ollama
BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}}
async def _ask(model: str, prompt: str, *, system: str = "",
tools: list | None = None, num_predict: int = 400) -> dict:
"""Un appel modèle ; renvoie {text, tool_calls}."""
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
text, calls = "", []
async for chunk in ollama.chat(
model, messages, tools=tools,
options={"temperature": 0, "num_predict": num_predict}, stream=True,
):
msg = chunk.get("message", {})
text += msg.get("content", "")
if msg.get("tool_calls"):
calls.extend(msg["tool_calls"])
if chunk.get("done"):
break
return {"text": text.strip(), "tool_calls": calls}
def _extract_code(text: str) -> str:
m = re.search(r"```(?:python)?\s*(.*?)```", text, re.S)
return (m.group(1) if m else text).strip()
def _run_python(code: str, test: str) -> bool:
"""Exécute code+test dans un sous-processus isolé (timeout 8 s)."""
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
f.write(code + "\n" + test)
path = f.name
try:
proc = subprocess.run(
[sys.executable, "-I", path],
capture_output=True, timeout=8,
)
return proc.returncode == 0
except (subprocess.SubprocessError, OSError):
return False
# ── Les 5 épreuves (score 0-20 chacune) ──────────────────────────────────
async def _task_tool_call(model: str) -> tuple[int, str]:
tools = [{
"type": "function",
"function": {
"name": "write_file",
"description": "Écrire un fichier",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
}]
try:
r = await _ask(
model,
"Crée le fichier bonjour.txt contenant exactement le texte : salut",
system="Utilise l'outil write_file pour créer le fichier demandé.",
tools=tools, num_predict=200,
)
except httpx.HTTPStatusError as exc:
if "does not support tools" in exc.response.text.lower():
return 0, "outils non supportés par ce modèle"
raise
for tc in r["tool_calls"]:
fn = tc.get("function", {})
if fn.get("name") == "write_file":
args = fn.get("arguments") or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
return 8, "appel d'outil aux arguments illisibles"
ok_path = "bonjour" in str(args.get("path", "")).lower()
ok_content = "salut" in str(args.get("content", "")).lower()
score = 10 + 5 * ok_path + 5 * ok_content
return score, "appel d'outil correct" if score == 20 else "appel partiel"
return 0, "aucun appel d'outil émis"
async def _task_code(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Écris une fonction Python `somme_pairs(nombres)` qui renvoie la somme "
"des nombres pairs de la liste. Réponds UNIQUEMENT avec le code.",
num_predict=300,
)
code = _extract_code(r["text"])
if "def somme_pairs" not in code:
return 0, "fonction absente"
test = (
"assert somme_pairs([1,2,3,4]) == 6\n"
"assert somme_pairs([]) == 0\n"
"assert somme_pairs([7,9]) == 0\n"
)
return (20, "code correct (3/3 tests)") if _run_python(code, test) \
else (6, "code présent mais tests échoués")
async def _task_instruction(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Quelle est la capitale de la France ? Réponds en 3 mots maximum.",
num_predict=30,
)
text = r["text"]
has_answer = "paris" in text.lower()
short = len(text.split()) <= 6
score = 12 * has_answer + 8 * short
return score, f"réponse « {text[:40]} »"
async def _task_json(model: str) -> tuple[int, str]:
r = await _ask(
model,
'Extrait les informations en JSON strict {"nom": ..., "ville": ...} '
"depuis : « Marie habite à Lyon ». Réponds UNIQUEMENT avec le JSON.",
num_predict=80,
)
m = re.search(r"\{.*\}", r["text"], re.S)
if not m:
return 0, "pas de JSON"
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return 5, "JSON invalide"
ok_nom = "marie" in str(data.get("nom", "")).lower()
ok_ville = "lyon" in str(data.get("ville", "")).lower()
score = 10 + 5 * ok_nom + 5 * ok_ville
detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle"
return score, detail
async def _task_format(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Liste exactement 3 fruits, un par ligne, chaque ligne préfixée par « - ».",
num_predict=60,
)
lines = [l for l in r["text"].splitlines() if l.strip().startswith("-")]
if len(lines) == 3:
return 20, "format exact"
if len(lines) >= 2:
return 10, f"{len(lines)} lignes au lieu de 3"
return 0, "format non respecté"
TASKS = [
("Appel d'outil", _task_tool_call),
("Code exécutable", _task_code),
("Consigne courte", _task_instruction),
("Extraction JSON", _task_json),
("Respect du format", _task_format),
]
async def run_bench(model: str) -> AsyncIterator[dict]:
"""Exécute les 5 épreuves en streamant la progression, stocke le score."""
total = 0
details = []
for name, fn in TASKS:
yield {"type": "task_start", "task": name}
task = asyncio.create_task(fn(model))
try:
while not task.done():
done, _ = await asyncio.wait({task}, timeout=10)
if not done:
# Empêche OpenResty/Nginx de fermer le SSE pendant une longue
# génération d'un gros modèle.
yield {"type": "heartbeat", "task": name}
score, detail = await task
except (OllamaError, httpx.HTTPError, OSError) as exc:
score, detail = 0, f"erreur : {str(exc)[:80]}"
except Exception as exc:
# Une épreuve défaillante ne doit pas couper silencieusement le SSE :
# elle vaut zéro et les autres épreuves continuent.
score, detail = 0, f"épreuve interrompue : {str(exc)[:80]}"
finally:
if not task.done():
task.cancel()
total += score
details.append({"task": name, "score": score, "detail": detail})
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
results = db.get_config_value(BENCH_KEY) or {}
results[model] = {"score": total, "details": details, "at": time.time()}
db.set_config_value(BENCH_KEY, results)
yield {"type": "done", "score": total, "details": details}
def get_scores() -> dict:
return db.get_config_value(BENCH_KEY) or {}
-160
View File
@@ -1,160 +0,0 @@
"""Moteur code : enveloppe Aider pour l'édition multi-fichiers fiable.
Aider apporte ce qui fait la force de Claude Code/Codex : formats d'édition
diff/search-replace robustes (même avec de petits modèles), repo map, et
commits git automatiques dans le workspace.
L'API Python d'Aider n'étant pas officiellement stable, la version est FIGÉE
dans requirements.txt (aider-chat==0.86.2) et tout l'import est local à la
fonction (l'app démarre même si Aider est absent).
"""
from __future__ import annotations
import os
import subprocess
import threading
from .config import settings
# Aider utilise le répertoire courant : un seul run à la fois.
_RUN_LOCK = threading.Lock()
def _confine(root: str, files: list[str] | None) -> list[str]:
"""Résout les fichiers demandés en restant confiné au workspace.
Écarte silencieusement tout chemin absolu ou toute remontée `../` qui
sortirait du workspace : le moteur code ne doit jamais toucher au disque
hors du workspace de la discussion.
"""
confined: list[str] = []
for f in files or []:
target = os.path.abspath(os.path.join(root, f))
if target == root or target.startswith(root + os.sep):
confined.append(target)
return confined
def ensure_git(root: str) -> None:
"""Initialise un dépôt git dans le workspace (requis pour les commits Aider)."""
os.makedirs(root, exist_ok=True)
if os.path.isdir(os.path.join(root, ".git")):
return
subprocess.run(["git", "init", "-q"], cwd=root, check=False)
subprocess.run(["git", "config", "user.email", "loki@local"], cwd=root, check=False)
subprocess.run(["git", "config", "user.name", "Loki"], cwd=root, check=False)
def available() -> bool:
"""Aider est-il installé ?"""
try:
import aider # noqa: F401
return True
except ImportError:
return False
# Familles spécialisées code, par ordre de préférence.
_CODE_MODEL_HINTS = (
"qwen3-coder", "qwen2.5-coder", "deepseek-coder", "codestral", "devstral",
"codegemma", "codellama", "starcoder", "coder",
)
async def pick_code_model(current: str, preference: str | None = None) -> str:
"""Choisit le modèle pour les tâches de code.
- préférence explicite (config code_model != "auto") -> respectée ;
- sinon, si un modèle spécialisé code est installé, on le prend (le plus
gros d'abord) : un petit modèle code bat un généraliste sur ce terrain ;
- sinon, on garde le modèle courant.
"""
if preference and preference != "auto":
return preference
from .ollama_client import ollama
try:
installed = await ollama.list_models_cached()
except Exception:
return current
candidates: list[tuple[int, int, str]] = [] # (rang_hint, -taille, nom)
for m in installed:
name = (m.get("name") or "").lower()
for rank, hint in enumerate(_CODE_MODEL_HINTS):
if hint in name:
candidates.append((rank, -(m.get("size") or 0), m["name"]))
break
if not candidates:
return current
candidates.sort()
return candidates[0][2]
def run_code_task(
instruction: str,
model: str,
files: list[str] | None = None,
root: str | None = None,
) -> dict:
"""Exécute une tâche de code via Aider (synchrone — lancer dans un thread).
Renvoie {ok, text, files, commit, commit_message, summary}.
"""
if not available():
return {
"ok": False,
"summary": "moteur code indisponible (aider non installé)",
"text": "", "files": [], "commit": None,
}
from aider.coders import Coder
from aider.io import InputOutput
from aider.models import Model
root = os.path.abspath(root or settings.workspace_dir)
ensure_git(root)
# Aider parle à Ollama via litellm : on pointe vers notre instance.
os.environ["OLLAMA_API_BASE"] = settings.ollama_host
with _RUN_LOCK:
prev_cwd = os.getcwd()
os.chdir(root)
try:
io = InputOutput(yes=True, pretty=False, fancy_input=False)
coder = Coder.create(
main_model=Model(f"ollama_chat/{model}"),
io=io,
fnames=_confine(root, files),
auto_commits=True,
stream=False,
use_git=True,
suggest_shell_commands=False,
detect_urls=False,
)
text = coder.run(instruction) or ""
edited = sorted(coder.aider_edited_files or [])
commit = getattr(coder, "last_aider_commit_hash", None)
commit_msg = getattr(coder, "last_aider_commit_message", None)
except Exception as exc: # aider peut lever des erreurs variées
return {
"ok": False,
"summary": f"échec moteur code : {exc}",
"text": "", "files": [], "commit": None,
}
finally:
os.chdir(prev_cwd)
summary = f"{len(edited)} fichier(s) modifié(s)"
if commit:
summary += f" · commit {str(commit)[:7]}"
return {
"ok": True,
"text": text.strip(),
"files": edited,
"commit": commit,
"commit_message": commit_msg,
"summary": summary,
}
-26
View File
@@ -1,26 +0,0 @@
"""Configuration de l'application, chargée depuis l'environnement."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Réglages globaux de Loki (surchargés par variables d'environnement)."""
ollama_host: str = "http://host.docker.internal:11434"
default_model: str = "gemma4:12b"
workspace_dir: str = "/workspace"
data_dir: str = "/data"
port: int = 8080
# Marqueur de build injecté à la construction de l'image (git sha court).
# Permet de vérifier que l'image déployée est bien à jour.
loki_version: str = "dev"
# Override manuel de la VRAM (Mo) si la détection GPU échoue dans le
# conteneur (utile quand Ollama tourne sur une autre machine).
gpu_vram_mb: int = 0
gpu_name: str = ""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()
-208
View File
@@ -1,208 +0,0 @@
"""Persistance SQLite : sessions et messages.
On utilise sqlite3 de la bibliothèque standard (zéro dépendance). Les écritures
sont rapides ; un verrou protège l'accès concurrent depuis les routes async.
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import time
import uuid
from .config import settings
_LOCK = threading.Lock()
_DB_PATH = os.path.join(settings.data_dir, "loki.db")
def _connect() -> sqlite3.Connection:
os.makedirs(settings.data_dir, exist_ok=True)
conn = sqlite3.connect(_DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
"""Crée les tables si elles n'existent pas."""
with _LOCK, _connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
model TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
model TEXT,
meta TEXT,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, created_at);
"""
)
# Migration douce : ajoute la colonne meta aux bases antérieures.
cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")}
if "meta" not in cols:
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
# Migration douce : résumé de conversation (mémoire compressée).
scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")}
if "summary" not in scols:
conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
# Migration douce : projet (sous-dossier de travail) de la session.
if "project" not in scols:
conn.execute("ALTER TABLE sessions ADD COLUMN project TEXT")
# Table clé/valeur pour la configuration de l'agent.
conn.execute(
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)"
)
def _now() -> float:
return time.time()
# ── Sessions ─────────────────────────────────────────────────────────────
def create_session(
title: str, model: str | None, project: str | None = None
) -> dict:
sid = uuid.uuid4().hex
now = _now()
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO sessions (id, title, model, project, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(sid, title, model, project, now, now),
)
return {"id": sid, "title": title, "model": model, "project": project,
"created_at": now, "updated_at": now, "message_count": 0}
def set_session_project(sid: str, project: str | None) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET project = ? WHERE id = ?", (project, sid)
)
def list_sessions() -> list[dict]:
with _LOCK, _connect() as conn:
rows = conn.execute(
"""
SELECT s.*, COUNT(m.id) AS message_count
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
GROUP BY s.id
ORDER BY s.updated_at DESC
"""
).fetchall()
return [dict(r) for r in rows]
def get_session(sid: str) -> dict | None:
with _LOCK, _connect() as conn:
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (sid,)).fetchone()
return dict(row) if row else None
def rename_session(sid: str, title: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
(title, _now(), sid),
)
def delete_session(sid: str) -> None:
with _LOCK, _connect() as conn:
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
def touch_session(sid: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), sid)
)
# ── Messages ─────────────────────────────────────────────────────────────
def add_message(
sid: str,
role: str,
content: str,
model: str | None,
meta: dict | None = None,
) -> dict:
mid = uuid.uuid4().hex
now = _now()
meta_json = json.dumps(meta, ensure_ascii=False) if meta else None
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO messages (id, session_id, role, content, model, meta, created_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(mid, sid, role, content, model, meta_json, now),
)
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?", (now, sid)
)
return {"id": mid, "session_id": sid, "role": role, "content": content,
"model": model, "meta": meta, "created_at": now}
def _row_to_message(row: sqlite3.Row) -> dict:
msg = dict(row)
msg["meta"] = json.loads(msg["meta"]) if msg.get("meta") else None
return msg
def list_messages(sid: str) -> list[dict]:
with _LOCK, _connect() as conn:
rows = conn.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at",
(sid,),
).fetchall()
return [_row_to_message(r) for r in rows]
def list_messages_for_model(sid: str) -> list[dict]:
"""Historique épuré (role/content) destiné au contexte du modèle."""
return [
{"role": m["role"], "content": m["content"]}
for m in list_messages(sid)
]
def set_session_summary(sid: str, summary: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET summary = ? WHERE id = ?", (summary, sid)
)
# ── Configuration (clé/valeur JSON) ──────────────────────────────────────
def get_config_value(key: str) -> dict | None:
with _LOCK, _connect() as conn:
row = conn.execute(
"SELECT value FROM config WHERE key = ?", (key,)
).fetchone()
return json.loads(row["value"]) if row else None
def set_config_value(key: str, value: dict) -> None:
payload = json.dumps(value, ensure_ascii=False)
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO config (key, value) VALUES (?, ?)"
" ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, payload),
)
-169
View File
@@ -1,169 +0,0 @@
"""Boosters de qualité pour petits modèles : plan-puis-exécute et auto-critique.
- make_plan : décompose une demande complexe en 3-5 étapes courtes. Un petit
modèle qui suit un plan écrit réussit bien mieux qu'en improvisant.
- self_review : une passe de critique éclair sur la réponse, puis une révision
si des défauts sont trouvés (activable : coûte un peu de latence).
"""
from __future__ import annotations
import logging
import re
import httpx
from .ollama_client import OllamaError, ollama
logger = logging.getLogger(__name__)
_PLAN_PROMPT = (
"Découpe la demande en 3 à 5 étapes courtes et concrètes, une par ligne, "
"numérotées « 1. », « 2. »… Pas d'introduction, pas de conclusion, "
"UNIQUEMENT les étapes, en français."
)
_PLAN_CODE_PROMPT = (
"Tu es architecte logiciel. Établis un plan d'IMPLÉMENTATION concret pour "
"cette application, en 3 à 5 étapes courtes (une par ligne, numérotées "
"« 1. », « 2. »…).\n"
"- Étape 1 = ARCHITECTURE : privilégie UN SEUL fichier autonome (ex. "
"index.html avec le CSS et le JS intégrés, sans dépendance externe) ; ne "
"sépare en plusieurs fichiers que si c'est vraiment indispensable.\n"
"- Étapes suivantes = construire UNE fonctionnalité concrète et testable à "
"la fois (structure/affichage, puis interactions, puis logique).\n"
"Reste RÉALISTE et réalisable en une passe : pas de dépendance externe, pas "
"de bibliothèque à installer, pas de « moteur IA » complexe si ce n'est pas "
"explicitement demandé — une logique simple en JavaScript suffit. "
"Pas d'introduction ni de conclusion, UNIQUEMENT les étapes, en français."
)
_CRITIQUE_PROMPT = (
"Tu es un relecteur exigeant. Voici une demande et la réponse d'un "
"assistant. Si la réponse est correcte et complète, réponds exactement "
"PARFAIT. Sinon, liste au plus 3 défauts concrets (erreurs, oublis, "
"incohérences), un par ligne."
)
_REVISE_PROMPT = (
"Réécris la réponse en corrigeant les défauts listés. Donne UNIQUEMENT la "
"réponse finale corrigée, sans commentaire sur la révision."
)
def needs_plan(message: str) -> bool:
"""Une demande assez longue/composée mérite un plan explicite."""
if len(message) < 120:
return False
connectors = len(re.findall(
r"\b(puis|ensuite|après|avec|ainsi que|et aussi|également)\b",
message, re.I,
))
return len(message) > 240 or connectors >= 2
async def _ask(
model: str,
system: str,
user: str,
*,
num_predict: int,
options: dict | None = None,
keep_alive: str | None = None,
) -> str:
"""Appel court au modèle.
``options`` doit contenir les options runner (num_ctx, num_batch…) du chat
principal : un appel avec des options divergentes force Ollama à recharger
le modèle en plein message.
"""
messages = [{"role": "system", "content": system},
{"role": "user", "content": user}]
opts = {**(options or {}), "temperature": 0.2, "num_predict": num_predict}
# think=False : un appel utilitaire (plan, critique) ne doit jamais
# « réfléchir » — sur un modèle thinking, la pensée dévore le budget et
# multiplie la latence. Repli sans le paramètre si le modèle le refuse.
think: bool | None = False
while True:
text = ""
try:
async for chunk in ollama.chat(
model, messages, options=opts, think=think,
keep_alive=keep_alive, stream=True,
):
text += chunk.get("message", {}).get("content", "")
if chunk.get("done"):
break
return text.strip()
except OllamaError as exc:
if think is False and "think" in str(exc).lower():
think = None
continue
raise
async def make_plan(
model: str,
message: str,
*,
code: bool = False,
options: dict | None = None,
keep_alive: str | None = None,
) -> list[str]:
"""Renvoie la liste des étapes (vide si échec — jamais bloquant).
``code=True`` bascule sur un plan d'ARCHITECTURE/implémentation concret
(fichiers, structure, fonctionnalités) plutôt qu'une liste d'objectifs.
"""
try:
raw = await _ask(
model, _PLAN_CODE_PROMPT if code else _PLAN_PROMPT,
message[:1200], num_predict=260,
options=options, keep_alive=keep_alive,
)
except (httpx.HTTPError, OSError) as exc:
logger.warning("Plan impossible : %s", exc)
return []
steps = []
for line in raw.splitlines():
line = line.strip()
m = re.match(r"^\d+[.)]\s*(.+)$", line)
if m:
steps.append(m.group(1).strip())
return steps[:5] if len(steps) >= 2 else []
async def self_review(
model: str,
request: str,
answer: str,
*,
options: dict | None = None,
keep_alive: str | None = None,
) -> str | None:
"""Critique puis révise la réponse. None si rien à corriger / échec."""
if len(answer) < 80:
return None
try:
critique = await _ask(
model,
_CRITIQUE_PROMPT,
f"Demande :\n{request[:800]}\n\nRéponse :\n{answer[:2500]}",
num_predict=180,
options=options, keep_alive=keep_alive,
)
if not critique or "PARFAIT" in critique.upper()[:40]:
return None
revised = await _ask(
model,
_REVISE_PROMPT,
f"Demande :\n{request[:800]}\n\nRéponse initiale :\n{answer[:2500]}"
f"\n\nDéfauts :\n{critique[:600]}",
num_predict=1500,
options=options, keep_alive=keep_alive,
)
# Garde-fou : une révision vide ou minuscule ne remplace rien.
return revised if len(revised) > len(answer) // 3 else None
except (httpx.HTTPError, OSError) as exc:
logger.warning("Auto-critique impossible : %s", exc)
return None
-122
View File
@@ -1,122 +0,0 @@
"""Point d'entrée FastAPI de Loki.
Sert l'API (/api/*) et, en production, le frontend React compilé (static/).
"""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from . import agent_config, coder, db, rag
from .config import settings
from .ollama_client import ollama
from .routes import (
benchmark, chat, config, files, git, mcp, models, projects, sessions,
shell, system,
)
async def _warm_default_model() -> None:
"""Précharge le modèle par défaut en VRAM au démarrage (best-effort)."""
import asyncio
import logging
await asyncio.sleep(2) # laisse le service démarrer
try:
cfg = agent_config.get_config(settings.default_model)
models.start_model_warm(
settings.default_model, cfg.get("keep_alive", "30m")
)
logging.getLogger(__name__).info(
"Préchargement du modèle %s lancé", settings.default_model
)
except Exception as exc: # best-effort
logging.getLogger(__name__).info(
"Préchargement au démarrage ignoré : %s", exc
)
@asynccontextmanager
async def lifespan(_: FastAPI):
import asyncio
db.init_db()
rag.init_table()
# Workspace en dépôt git : requis pour les commits du moteur code (Aider).
coder.ensure_git(settings.workspace_dir)
# Préchargement du modèle par défaut, sans bloquer le démarrage.
asyncio.create_task(_warm_default_model())
yield
# Ferme le pool HTTP partagé vers Ollama et les sessions MCP.
await ollama.aclose()
from .mcp_client import manager as mcp_manager
await mcp_manager.aclose()
app = FastAPI(
title="Loki", description="Agent IA local sur Ollama", lifespan=lifespan
)
# En dev, le front tourne sur Vite (5173). On autorise le CORS large ;
# en prod le front est servi par le même origin, donc sans impact.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(models.router)
app.include_router(sessions.router)
app.include_router(chat.router)
app.include_router(files.router)
app.include_router(config.router)
app.include_router(shell.router)
app.include_router(system.router)
app.include_router(benchmark.router)
app.include_router(git.router)
app.include_router(mcp.router)
app.include_router(projects.router)
@app.get("/api/health")
async def health() -> dict:
return {"status": "ok", "service": "loki", "version": settings.loki_version}
@app.get("/api/version")
async def version() -> dict:
"""Marqueur de build — pour vérifier que l'image déployée est à jour."""
return {"version": settings.loki_version}
# ── Service du frontend compilé (présent uniquement en image Docker) ─────
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "..", "static")
if os.path.isdir(_STATIC_DIR):
app.mount(
"/assets",
StaticFiles(directory=os.path.join(_STATIC_DIR, "assets")),
name="assets",
)
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
"""Fichier statique racine s'il existe (favicon…), sinon index.html.
Sans ce test, /favicon.svg recevait index.html : aucun favicon ne
s'affichait en production.
"""
static_root = os.path.abspath(_STATIC_DIR)
candidate = os.path.abspath(os.path.join(static_root, full_path))
if (
full_path
and candidate.startswith(static_root + os.sep)
and os.path.isfile(candidate)
):
return FileResponse(candidate)
return FileResponse(os.path.join(static_root, "index.html"))
-341
View File
@@ -1,341 +0,0 @@
"""Client MCP : catalogue préconfiguré + sessions vers les serveurs activés.
Un serveur désactivé n'est jamais démarré et n'expose aucun outil au modèle
(chaque outil injecté coûte du contexte). Connexion lazy au premier message,
session réutilisée ensuite. Toute panne est non bloquante pour le chat.
"""
from __future__ import annotations
import asyncio
import logging
import re
import shlex
from contextlib import AsyncExitStack
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from . import db
logger = logging.getLogger(__name__)
MCP_KEY = "mcp"
# Catalogue embarqué. command=None => serveur "custom" (commande utilisateur).
CATALOG: dict[str, dict] = {
"playwright": {
"label": "Playwright (navigateur)",
"description": "Pilote un vrai navigateur : naviguer, cliquer, lire "
"la console, captures. L'agent teste réellement ses pages.",
"command": ["npx", "@playwright/mcp@latest", "--headless"],
"url_param": False,
"env_params": [],
# Limite le nombre d'outils injectés dans le prompt.
"expose": [
"browser_navigate", "browser_click", "browser_type",
"browser_snapshot", "browser_console_messages",
"browser_take_screenshot",
],
},
"context7": {
"label": "Context7 (documentation)",
"description": "Documentation à jour de n'importe quelle librairie ou "
"framework (React, FastAPI, Tailwind…).",
"command": ["npx", "-y", "@upstash/context7-mcp"],
"url_param": False,
"env_params": [],
"expose": None,
},
"fetch": {
"label": "Fetch (lecture web)",
"description": "Lit proprement n'importe quelle URL (markdown épuré).",
"command": ["python", "-m", "mcp_server_fetch"],
"url_param": False,
"env_params": [],
"expose": None,
},
"searxng": {
"label": "SearxNG (recherche web)",
"description": "Vraie recherche web via une instance SearxNG "
"(renseigner SEARXNG_URL).",
"command": ["npx", "-y", "mcp-searxng"],
"url_param": False,
"env_params": ["SEARXNG_URL"],
"expose": None,
},
"custom": {
"label": "Personnalisé",
"description": "N'importe quel serveur MCP : colle une commande "
"(stdio) ou une URL (streamable HTTP).",
"command": None,
"url_param": True,
"env_params": [],
"expose": None,
},
}
def get_mcp_state() -> dict:
"""État activé/params de chaque serveur du catalogue (défaut : désactivé)."""
stored = db.get_config_value(MCP_KEY) or {}
return {
sid: {
"enabled": bool(stored.get(sid, {}).get("enabled", False)),
"params": dict(stored.get(sid, {}).get("params", {})),
}
for sid in CATALOG
}
def set_mcp_state(sid: str, *, enabled: bool, params: dict) -> dict:
if sid not in CATALOG:
raise KeyError(sid)
state = get_mcp_state()
state[sid] = {"enabled": enabled, "params": dict(params)}
db.set_config_value(MCP_KEY, state)
return state
_CALL_TIMEOUT = 30.0
# Généreux : le premier lancement d'un serveur npx (Playwright, Context7…)
# télécharge le paquet — souvent bien plus de 20 s. Les démarrages suivants
# sont instantanés (cache npm).
_CONNECT_TIMEOUT = 90.0
_MAX_RESULT_CHARS = 8000
def _safe_tool_name(name: str) -> str:
"""Nom d'outil compatible function-calling (lettres/chiffres/underscore).
Les noms MCP peuvent contenir des tirets (« resolve-library-id ») que les
grammaires de tool-calling et les modèles mélangent avec des underscores —
source de « Tool not found ». On expose une version assainie et on garde
la correspondance vers le vrai nom.
"""
return re.sub(r"[^a-zA-Z0-9_]", "_", name)
class _ServerConn:
"""Session vivante vers un serveur MCP (process stdio + handshake)."""
def __init__(self, sid: str) -> None:
self.sid = sid
self.stack = AsyncExitStack()
self.session: ClientSession | None = None
self.tools: list[dict] = [] # définitions format Ollama
# nom exposé au modèle -> vrai nom d'outil MCP
self.name_map: dict[str, str] = {}
async def start(self) -> None:
entry = CATALOG[self.sid]
params = get_mcp_state()[self.sid]["params"]
spec = params.get("command", "").strip()
if entry["command"] is None and spec.startswith(("http://", "https://")):
# Serveur "custom" en streamable HTTP (URL collée par l'utilisateur).
from mcp.client.streamable_http import streamablehttp_client
read, write, _ = await self.stack.enter_async_context(
streamablehttp_client(spec)
)
else:
if entry["command"] is None:
# Serveur "custom" : commande stdio saisie par l'utilisateur.
command = shlex.split(spec)
if not command:
raise ValueError("commande du serveur personnalisé vide")
else:
command = list(entry["command"])
# Paramètres obligatoires (ex. SEARXNG_URL) : refus clair AVANT le
# lancement, plutôt qu'un échec cryptique à chaque appel d'outil.
missing = [k for k in entry["env_params"] if not params.get(k)]
if missing:
raise ValueError(
f"{', '.join(missing)} requis — renseigne ce champ dans la "
"carte du serveur (Configuration → Serveurs MCP)"
)
env = {k: params[k] for k in entry["env_params"] if params.get(k)}
server = StdioServerParameters(
command=command[0], args=command[1:], env=env or None
)
read, write = await self.stack.enter_async_context(
stdio_client(server)
)
self.session = await self.stack.enter_async_context(
ClientSession(read, write)
)
await asyncio.wait_for(self.session.initialize(), _CONNECT_TIMEOUT)
listed = await asyncio.wait_for(self.session.list_tools(), _CONNECT_TIMEOUT)
expose = entry.get("expose")
self.tools = []
self.name_map = {}
for t in listed.tools:
if expose is not None and t.name not in expose:
continue
exposed = f"mcp_{self.sid}_{_safe_tool_name(t.name)}"
self.name_map[exposed] = t.name
self.tools.append({
"type": "function",
"function": {
"name": exposed,
"description": (t.description or t.name)[:400],
"parameters": t.inputSchema
or {"type": "object", "properties": {}},
},
})
async def close(self) -> None:
try:
await self.stack.aclose()
except Exception: # process déjà mort : sans importance
pass
class McpManager:
"""Sessions MCP lazy + dispatch d'appels d'outils, jamais bloquant."""
def __init__(self) -> None:
self._conns: dict[str, _ServerConn] = {}
self._errors: dict[str, str] = {}
self._notices: list[str] = []
self._lock = asyncio.Lock()
async def _ensure(self, sid: str) -> _ServerConn | None:
if sid in self._conns:
return self._conns[sid]
try:
conn = _ServerConn(sid)
await conn.start()
except Exception as exc:
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
msg = (
"délai de démarrage dépassé — premier lancement d'un "
"serveur npx (téléchargement) ou paquet absent de l'image. "
"Réessaie au prochain message."
)
else:
msg = str(exc)[:300] or exc.__class__.__name__
logger.warning("Serveur MCP %s indisponible : %s", sid, msg)
self._errors[sid] = msg
self._notices.append(
f"Serveur MCP « {CATALOG[sid]['label']} » indisponible : {msg}"
)
return None
self._errors.pop(sid, None)
self._conns[sid] = conn
return conn
async def tool_definitions(self) -> list[dict]:
"""Outils des serveurs activés (connexion lazy, pannes ignorées)."""
defs: list[dict] = []
async with self._lock:
state = get_mcp_state()
# Ferme les serveurs désactivés entre-temps.
for sid in [s for s in self._conns if not state[s]["enabled"]]:
await self._conns.pop(sid).close()
for sid, st in state.items():
if not st["enabled"]:
continue
conn = await self._ensure(sid)
if conn:
defs.extend(conn.tools)
return defs
def _resolve(self, prefixed_name: str) -> tuple[str, str] | None:
"""(sid, vrai nom d'outil) depuis le nom exposé au modèle.
Résolution par table de correspondance, avec tolérance : les modèles
confondent parfois tirets et underscores dans les noms d'outils.
"""
wanted = _safe_tool_name(prefixed_name)
for sid, conn in self._conns.items():
for exposed, real in conn.name_map.items():
if exposed == prefixed_name or exposed == wanted:
return sid, real
# Repli : découpage mcp_<sid>_<outil> (serveur pas encore connecté).
try:
_, sid, tool = prefixed_name.split("_", 2)
return sid, tool
except ValueError:
return None
async def call_tool(self, prefixed_name: str, args: dict) -> dict:
resolved = self._resolve(prefixed_name)
if resolved is None:
return {"ok": False, "content": "", "summary": "nom d'outil invalide"}
sid, tool = resolved
async with self._lock:
conn = self._conns.get(sid) or await self._ensure(sid)
if conn is None or conn.session is None:
return {"ok": False, "content": "",
"summary": f"serveur MCP {sid} indisponible"}
# Serveur (re)connecté après le repli : re-résout via sa table.
if conn.name_map:
tool = conn.name_map.get(prefixed_name) or conn.name_map.get(
_safe_tool_name(prefixed_name), tool
)
try:
result = await asyncio.wait_for(
conn.session.call_tool(tool, args or {}), _CALL_TIMEOUT
)
except Exception as exc:
# Session probablement morte : on la ferme, retry au prochain tour.
async with self._lock:
dead = self._conns.pop(sid, None)
if dead:
await dead.close()
return {"ok": False, "content": "",
"summary": f"échec MCP : {str(exc)[:200]}"}
parts = [
c.text for c in result.content
if getattr(c, "type", "") == "text" and getattr(c, "text", "")
]
content = "\n".join(parts)[:_MAX_RESULT_CHARS]
ok = not bool(getattr(result, "isError", False))
return {
"ok": ok,
"content": content,
"summary": (content.splitlines()[0][:120] if content else "terminé")
if ok else (content[:120] or "erreur outil MCP"),
}
def statuses(self) -> dict[str, dict]:
state = get_mcp_state()
out = {}
for sid, st in state.items():
if sid in self._conns:
s = "connected"
elif sid in self._errors:
s = "error"
else:
s = "inactive"
out[sid] = {
"state": s if st["enabled"] else "inactive",
"error": self._errors.get(sid),
"tools": len(self._conns[sid].tools) if sid in self._conns else 0,
}
return out
def notices(self) -> list[str]:
out, self._notices = self._notices, []
return out
async def test_server(self, sid: str) -> dict:
"""Connexion d'essai indépendante (n'altère pas les sessions)."""
conn = _ServerConn(sid)
try:
await conn.start()
return {"ok": True,
"tools": [d["function"]["name"] for d in conn.tools],
"error": None}
except Exception as exc:
return {"ok": False, "tools": [],
"error": str(exc)[:300] or exc.__class__.__name__}
finally:
await conn.close()
async def aclose(self) -> None:
for conn in list(self._conns.values()):
await conn.close()
self._conns.clear()
manager = McpManager()
-110
View File
@@ -1,110 +0,0 @@
"""Mémoire de conversation compressée — le vrai levier des petits modèles.
Un petit modèle se noie dans un long historique : il oublie la consigne, part
en boucle, et un grand num_ctx le fait déborder du GPU. On garde donc :
[invite système] + [résumé compact des anciens tours] + [N derniers messages]
Le résumé est régénéré en arrière-plan (après la réponse, sans latence pour
l'utilisateur) dès que l'historique dépasse le seuil.
"""
from __future__ import annotations
import logging
import httpx
from . import db
from .ollama_client import OllamaError, ollama
logger = logging.getLogger(__name__)
# Nombre de messages récents passés tels quels au modèle.
KEEP_RECENT = 10
# Au-delà de ce total, les anciens tours sont compressés dans le résumé.
SUMMARIZE_AFTER = KEEP_RECENT + 6
_SUMMARY_PROMPT = (
"Résume la conversation ci-dessous en français, en 10 lignes maximum. "
"Conserve impérativement : l'objectif de l'utilisateur, les décisions "
"prises, les fichiers créés/modifiés et leur rôle, et les points encore "
"ouverts. Réponds UNIQUEMENT par le résumé."
)
def build_convo(sid: str, system_prompt: str) -> list[dict]:
"""Construit le contexte : système + résumé éventuel + messages récents.
Le résumé est fusionné DANS l'invite système plutôt qu'ajouté comme second
message système : de nombreux templates (Gemma, Mistral…) rejettent tout
message système qui n'est pas le premier de la liste.
"""
session = db.get_session(sid) or {}
summary = (session.get("summary") or "").strip()
messages = db.list_messages_for_model(sid)
if summary and len(messages) > KEEP_RECENT:
system_prompt = (
f"{system_prompt}\n\nRésumé des échanges précédents :\n{summary}"
)
messages = messages[-KEEP_RECENT:]
return [{"role": "system", "content": system_prompt}, *messages]
async def maybe_summarize(
sid: str,
model: str,
*,
options: dict | None = None,
keep_alive: str | None = None,
) -> None:
"""Compresse les anciens tours dans le résumé (tâche d'arrière-plan).
``options`` doit reprendre les options runner du chat (num_ctx…) pour ne
pas déclencher un rechargement du modèle après chaque réponse.
"""
try:
messages = db.list_messages_for_model(sid)
if len(messages) <= SUMMARIZE_AFTER:
return
session = db.get_session(sid) or {}
previous = (session.get("summary") or "").strip()
old = messages[:-KEEP_RECENT]
transcript = "\n".join(
f"[{m['role']}] {m['content'][:600]}" for m in old
)[-8000:]
if previous:
transcript = f"[résumé existant] {previous}\n{transcript}"
messages = [
{"role": "system", "content": _SUMMARY_PROMPT},
{"role": "user", "content": transcript},
]
opts = {**(options or {}), "temperature": 0.2, "num_predict": 350}
# think=False : le résumé d'arrière-plan ne doit pas « réfléchir »
# (latence ×3 sur un modèle thinking). Repli si paramètre refusé.
think: bool | None = False
while True:
text = ""
try:
async for chunk in ollama.chat(
model, messages, options=opts, think=think,
keep_alive=keep_alive, stream=True,
):
text += chunk.get("message", {}).get("content", "")
if chunk.get("done"):
break
break
except OllamaError as exc:
if think is False and "think" in str(exc).lower():
think = None
continue
raise
if text.strip():
db.set_session_summary(sid, text.strip())
except (httpx.HTTPError, OSError) as exc:
# Best-effort : un échec de résumé ne doit jamais gêner le chat.
logger.warning("Résumé de session %s impossible : %s", sid, exc)
-154
View File
@@ -1,154 +0,0 @@
"""Mémoire en notes Markdown : l'agent écrit lui-même ce qu'il veut retenir.
Repris d'AJEAN (github.com/nathaninline/jean) : des notes lisibles rangées
dans ``$DATA_DIR/MEMORY/``, que le modèle consulte et enregistre EXPLICITEMENT.
Face au RAG vectoriel, trois avantages décisifs :
- aucun modèle d'embedding requis (marche dès l'installation) ;
- le contenu est inspectable et modifiable à la main ;
- rien n'est mémorisé « à l'insu » : pas de vieille demande sans rapport qui
ressurgit au milieu d'une nouvelle discussion.
Trois modes (config ``memory_mode``) :
off — rien du tout ;
ondemand — les outils memory_search / memory_save sont proposés au modèle ;
always — idem, plus l'injection automatique des notes pertinentes.
"""
from __future__ import annotations
import os
import re
import time
import unicodedata
from .config import settings
MODES = ("off", "ondemand", "always")
# Bornes : une note reste une note, pas une archive.
_MAX_NOTE_CHARS = 4000
_MAX_NOTES = 200
_EXCERPT_CHARS = 400
def _dir() -> str:
path = os.path.join(os.path.abspath(settings.data_dir), "MEMORY")
os.makedirs(path, exist_ok=True)
return path
def _slug(title: str) -> str:
"""Nom de fichier sûr dérivé du titre (confiné au dossier MEMORY)."""
text = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode()
text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
return (text or "note")[:60]
def _tokens(text: str) -> set[str]:
"""Mots significatifs, sans accents ni casse (>= 3 lettres)."""
text = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode()
return {w for w in re.findall(r"[a-z0-9]{3,}", text.lower())}
def save_note(title: str, content: str) -> dict:
"""Crée ou remplace une note. Renvoie {ok, summary}."""
title = (title or "").strip()
content = (content or "").strip()
if not title:
return {"ok": False, "summary": "titre vide"}
if not content:
return {"ok": False, "summary": "contenu vide"}
content = content[:_MAX_NOTE_CHARS]
path = os.path.join(_dir(), f"{_slug(title)}.md")
existed = os.path.isfile(path)
with open(path, "w", encoding="utf-8") as f:
f.write(f"# {title}\n\n{content}\n")
_prune()
verb = "mise à jour" if existed else "enregistrée"
return {"ok": True, "summary": f"note {verb} : {title}"}
def _prune() -> None:
"""Garde les notes les plus récentes (borne dure, jamais de purge totale)."""
files = [os.path.join(_dir(), n) for n in os.listdir(_dir()) if n.endswith(".md")]
if len(files) <= _MAX_NOTES:
return
files.sort(key=lambda p: os.path.getmtime(p), reverse=True)
for path in files[_MAX_NOTES:]:
try:
os.remove(path)
except OSError:
pass
def _read_all() -> list[tuple[str, str]]:
"""[(titre, corps)] de toutes les notes."""
out = []
try:
names = sorted(os.listdir(_dir()))
except OSError:
return out
for name in names:
if not name.endswith(".md"):
continue
try:
with open(os.path.join(_dir(), name), encoding="utf-8", errors="replace") as f:
raw = f.read()
except OSError:
continue
first, _, body = raw.partition("\n")
title = first.lstrip("# ").strip() or name[:-3]
out.append((title, body.strip()))
return out
def search_notes(query: str, limit: int = 3) -> list[dict]:
"""Notes les plus proches de la requête (recouvrement de mots-clés)."""
wanted = _tokens(query)
if not wanted:
return []
scored: list[tuple[int, str, str]] = []
for title, body in _read_all():
score = len(wanted & _tokens(f"{title} {body}"))
if score:
scored.append((score, title, body))
scored.sort(key=lambda item: -item[0])
return [
{"title": title, "content": body[:_EXCERPT_CHARS]}
for _, title, body in scored[:limit]
]
def list_notes() -> list[dict]:
"""Inventaire des notes (pour l'UI / l'inspection)."""
notes = []
for title, body in _read_all():
path = os.path.join(_dir(), f"{_slug(title)}.md")
notes.append({
"title": title,
"chars": len(body),
"updated_at": os.path.getmtime(path) if os.path.isfile(path) else time.time(),
})
return sorted(notes, key=lambda n: -n["updated_at"])
def delete_note(title: str) -> bool:
path = os.path.join(_dir(), f"{_slug(title)}.md")
if not os.path.isfile(path):
return False
os.remove(path)
return True
def recall_block(message: str) -> str | None:
"""Bloc de contexte à injecter en mode ``always`` (None si rien de net)."""
hits = search_notes(message, limit=2)
if not hits:
return None
body = "\n\n".join(f"## {h['title']}\n{h['content']}" for h in hits)
return (
"Notes que tu avais enregistrées et qui semblent liées à cette demande "
"(ignore-les si elles ne s'appliquent pas) :\n" + body
)
-251
View File
@@ -1,251 +0,0 @@
"""Client HTTP léger pour l'API Ollama.
On utilise httpx directement (plutôt que le SDK) pour garder le contrôle
total sur le streaming et n'embarquer aucune dépendance superflue.
"""
from __future__ import annotations
import asyncio
import json
import time
from typing import AsyncIterator
import httpx
from .config import settings
class OllamaError(RuntimeError):
"""Erreur renvoyée par Ollama (statut HTTP ≥ 400 ou champ ``error`` dans le flux).
Ollama signale certains échecs *au milieu* d'un flux streaming (HTTP 200)
via une ligne JSON ``{"error": "..."}`` — typiquement un débordement mémoire
ou un contexte trop grand. On lève alors cette exception pour que l'appelant
la remonte à l'utilisateur au lieu de l'avaler silencieusement.
"""
# Connexion rapide à échouer si Ollama est injoignable, mais lecture sans limite :
# une génération longue (ou un chargement de modèle sur CPU) ne doit pas couper.
_STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0)
# Un chat déclenche plusieurs appels Ollama (routage, embed, plan, agent…) :
# un pool keep-alive partagé évite un handshake TCP à chaque appel.
_LIMITS = httpx.Limits(
max_connections=20, max_keepalive_connections=10, keepalive_expiry=30.0
)
async def _raise_for_stream_status(resp: httpx.Response) -> None:
"""Lève une ``OllamaError`` détaillée si la réponse streaming est en erreur.
Sur une réponse en flux, ``raise_for_status`` n'inclut pas le corps ; on le
lit explicitement pour exposer le message d'Ollama (modèle absent, etc.).
"""
if resp.status_code < 400:
return
body = await resp.aread()
detail = body.decode(errors="replace").strip()
try:
detail = json.loads(detail).get("error", detail)
except (json.JSONDecodeError, AttributeError):
pass
raise OllamaError(f"Ollama a renvoyé {resp.status_code} : {detail[:500]}")
class OllamaClient:
"""Enveloppe asynchrone autour de l'API REST d'Ollama."""
def __init__(self, host: str | None = None) -> None:
self.host = (host or settings.ollama_host).rstrip("/")
self._client: httpx.AsyncClient | None = None
# Cache court de /api/tags : la liste des modèles installés change
# rarement mais est consultée par plusieurs modules à chaque message.
self._tags_at = 0.0
self._tags: list[dict] = []
def _http(self) -> httpx.AsyncClient:
"""Client partagé (pool keep-alive), créé paresseusement.
Un seul worker uvicorn / une seule boucle : la création lazy est sûre.
Le garde ``is_closed`` recrée le client si un arrêt l'a fermé.
"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=_STREAM_TIMEOUT, follow_redirects=True, limits=_LIMITS
)
return self._client
async def aclose(self) -> None:
"""Ferme le pool partagé (appelé au shutdown de l'app)."""
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
async def ping(self) -> dict:
"""Vérifie la connexion et renvoie la version d'Ollama."""
resp = await self._http().get(f"{self.host}/api/version", timeout=5.0)
resp.raise_for_status()
return resp.json()
async def list_models(self) -> list[dict]:
"""Liste les modèles installés localement (/api/tags), sans cache."""
resp = await self._http().get(f"{self.host}/api/tags", timeout=10.0)
resp.raise_for_status()
models = resp.json().get("models", [])
self._tags, self._tags_at = models, time.monotonic()
return models
async def list_models_cached(self, ttl: float = 30.0) -> list[dict]:
"""Comme ``list_models`` mais avec un cache court partagé.
Utilisé par les chemins chauds (routage code, résolution embed, route
/api/models) pour ne pas marteler /api/tags à chaque message.
"""
if self._tags and time.monotonic() - self._tags_at < ttl:
return self._tags
return await self.list_models()
def invalidate_tags_cache(self) -> None:
"""Force un rafraîchissement après un pull ou une suppression de modèle."""
self._tags_at = 0.0
self._tags = []
async def embed(
self, model: str, texts: list[str], keep_alive: str = "30m"
) -> list[list[float]]:
"""Vecteurs d'embedding pour une liste de textes (/api/embed).
``keep_alive`` long : le modèle d'embedding est minuscule (<0,5 Go) et
sollicité à chaque message (recall + indexation) — le laisser chargé
évite un aller-retour VRAM permanent avec le modèle de chat.
"""
resp = await self._http().post(
f"{self.host}/api/embed",
json={"model": model, "input": texts, "keep_alive": keep_alive},
timeout=30.0,
)
resp.raise_for_status()
return resp.json().get("embeddings", [])
async def ps(self) -> list[dict]:
"""Modèles actuellement chargés et leur répartition VRAM/CPU (/api/ps)."""
resp = await self._http().get(f"{self.host}/api/ps", timeout=5.0)
resp.raise_for_status()
return resp.json().get("models", [])
async def show(self, name: str) -> dict:
"""Métadonnées détaillées d'un modèle (/api/show)."""
resp = await self._http().post(
f"{self.host}/api/show", json={"name": name}, timeout=15.0
)
resp.raise_for_status()
return resp.json()
async def delete_model(self, name: str) -> dict:
"""Supprime un modèle installé (/api/delete)."""
resp = await self._http().request(
"DELETE", f"{self.host}/api/delete", json={"model": name}, timeout=30.0
)
resp.raise_for_status()
self.invalidate_tags_cache()
return resp.json() if resp.content else {"status": "success"}
async def pull_model(self, name: str) -> AsyncIterator[dict]:
"""Télécharge un modèle en streamant la progression (/api/pull)."""
async with self._http().stream(
"POST",
f"{self.host}/api/pull",
json={"name": name},
timeout=_STREAM_TIMEOUT,
) as resp:
await _raise_for_stream_status(resp)
async for line in resp.aiter_lines():
if not line.strip():
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(chunk, dict) and chunk.get("error"):
raise OllamaError(str(chunk["error"]))
yield chunk
self.invalidate_tags_cache()
async def warm(
self, model: str, keep_alive: str = "30m", options: dict | None = None
) -> dict:
"""Précharge un modèle en VRAM sans générer (/api/generate sans prompt).
Le paramètre keep_alive fixe la durée de rétention en mémoire. Les
`options` (num_ctx, num_batch, num_gpu…) DOIVENT correspondre à celles du
chat : sinon Ollama chargerait un runner distinct puis en rechargerait un
autre au premier message — un double chargement très coûteux pour un gros
modèle.
"""
payload: dict = {"model": model, "keep_alive": keep_alive, "stream": False}
if options:
payload["options"] = options
# Le chargement se fait désormais en tâche de fond côté API Loki. On lui
# laisse jusqu'à dix minutes pour les gros modèles ou un stockage lent.
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
for attempt in range(2):
resp = await self._http().post(
f"{self.host}/api/generate", json=payload, timeout=timeout
)
try:
# Inclut le corps JSON d'Ollama dans l'erreur (OOM, runner…),
# contrairement à raise_for_status qui ne montrait que « 500 ».
await _raise_for_stream_status(resp)
except OllamaError:
if resp.status_code >= 500 and attempt == 0:
await asyncio.sleep(2)
continue
raise
return resp.json()
raise OllamaError("préchargement interrompu sans réponse")
async def chat(
self,
model: str,
messages: list[dict],
*,
tools: list[dict] | None = None,
options: dict | None = None,
think: bool | None = None,
keep_alive: str | None = None,
stream: bool = True,
) -> AsyncIterator[dict]:
"""Conversation avec le modèle, en streaming token par token."""
payload: dict = {"model": model, "messages": messages, "stream": stream}
if tools:
payload["tools"] = tools
if options:
payload["options"] = options
# think=False désactive le raisonnement des modèles « thinking ».
if think is not None:
payload["think"] = think
# keep_alive : durée de maintien du modèle en VRAM après la réponse.
if keep_alive is not None:
payload["keep_alive"] = keep_alive
async with self._http().stream(
"POST", f"{self.host}/api/chat", json=payload, timeout=_STREAM_TIMEOUT
) as resp:
await _raise_for_stream_status(resp)
async for line in resp.aiter_lines():
if not line.strip():
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
# Ligne partielle / non-JSON : on l'ignore plutôt que de
# faire planter tout le flux.
continue
# Échec en cours de génération (OOM, contexte trop grand…) :
# Ollama l'émet dans le flux avec HTTP 200. On le remonte.
if isinstance(chunk, dict) and chunk.get("error"):
raise OllamaError(str(chunk["error"]))
yield chunk
ollama = OllamaClient()
-155
View File
@@ -1,155 +0,0 @@
"""Mémoire long-terme (RAG) : l'agent se souvient des anciennes sessions.
Chaque échange (question + réponse) est vectorisé via /api/embed d'Ollama et
stocké en SQLite. À chaque nouveau message, on recherche les souvenirs les
plus proches (cosinus) dans les AUTRES sessions et on les injecte en contexte.
Tout est best-effort : sans modèle d'embedding installé, le RAG se désactive
silencieusement (aucun impact sur le chat).
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
import time
import uuid
import httpx
from . import db
from .ollama_client import ollama
logger = logging.getLogger(__name__)
# Modèles d'embedding reconnus, par ordre de préférence.
_EMBED_HINTS = ("nomic-embed", "mxbai-embed", "bge-", "snowflake-arctic-embed",
"all-minilm", "embed")
_TOP_K = 3
# Seuil de similarité volontairement élevé : une mémoire n'est rappelée que si
# elle est FORTEMENT proche de la demande. Trop bas (0.45), un « jeu d'échecs »
# rappelait une « appli sport » et déroutait le modèle.
_MIN_SCORE = 0.6
_MAX_MEMORIES = 2000 # au-delà, on élague les plus anciens
_embed_model_cache: dict = {"value": None, "checked_at": 0.0}
def init_table() -> None:
with db._LOCK, db._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
content TEXT NOT NULL,
embedding TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
async def resolve_embed_model(preference: str | None = None) -> str | None:
"""Trouve le modèle d'embedding à utiliser (None = RAG indisponible)."""
if preference and preference != "auto":
return preference
# Cache 60 s pour ne pas marteler /api/tags.
now = time.time()
if now - _embed_model_cache["checked_at"] < 60:
return _embed_model_cache["value"]
value = None
try:
for m in await ollama.list_models_cached():
name = (m.get("name") or "").lower()
if any(h in name for h in _EMBED_HINTS):
value = m["name"]
break
except (httpx.HTTPError, OSError):
value = None
_embed_model_cache.update(value=value, checked_at=now)
return value
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
return dot / (na * nb) if na and nb else 0.0
def _score_rows(qvec: list[float], rows: list) -> list[str]:
"""Scoring cosinus sur toutes les mémoires — CPU pur, à lancer via to_thread.
Jusqu'à _MAX_MEMORIES vecteurs : la boucle Python bloquerait l'event loop
(et donc tous les SSE en cours) pendant plusieurs dizaines de ms.
"""
scored: list[tuple[float, str]] = []
for row in rows:
try:
score = _cosine(qvec, json.loads(row["embedding"]))
except (ValueError, TypeError):
continue
if score >= _MIN_SCORE:
scored.append((score, row["content"]))
scored.sort(reverse=True)
return [c for _, c in scored[:_TOP_K]]
async def index_exchange(
sid: str, user_text: str, assistant_text: str, *, embed_model: str | None
) -> None:
"""Indexe un échange terminé (tâche d'arrière-plan, best-effort)."""
model = await resolve_embed_model(embed_model)
if not model:
return
content = f"Q: {user_text[:500]}\nR: {assistant_text[:800]}"
try:
vectors = await ollama.embed(model, [content])
if not vectors:
return
with db._LOCK, db._connect() as conn:
conn.execute(
"INSERT INTO memories (id, session_id, content, embedding, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(uuid.uuid4().hex, sid, content,
json.dumps(vectors[0]), time.time()),
)
# Élagage des souvenirs les plus anciens.
conn.execute(
"DELETE FROM memories WHERE id IN ("
" SELECT id FROM memories ORDER BY created_at DESC"
f" LIMIT -1 OFFSET {_MAX_MEMORIES})"
)
except (httpx.HTTPError, OSError) as exc:
logger.warning("Indexation RAG impossible : %s", exc)
async def recall(
sid: str, query: str, *, embed_model: str | None
) -> list[str]:
"""Souvenirs pertinents issus des AUTRES sessions (top-k, score minimal)."""
model = await resolve_embed_model(embed_model)
if not model:
return []
try:
vectors = await ollama.embed(model, [query[:800]])
if not vectors:
return []
qvec = vectors[0]
with db._LOCK, db._connect() as conn:
rows = conn.execute(
"SELECT content, embedding FROM memories WHERE session_id != ?",
(sid,),
).fetchall()
return await asyncio.to_thread(_score_rows, qvec, rows)
except (httpx.HTTPError, OSError) as exc:
logger.warning("Rappel RAG impossible : %s", exc)
return []
-69
View File
@@ -1,69 +0,0 @@
"""Routeur automatique : moteur code (Aider) ou boucle agent classique ?
Invisible pour l'utilisateur : chaque message est classé par une heuristique
lexicale instantanée. Les cas ambigus partent vers la boucle agent, qui garde
l'outil `code_task` en secours — plus d'appel LLM bloquant avant le premier
token (l'ancien micro-classifieur coûtait un aller-retour modèle complet et
chargeait un runner divergent).
"""
from __future__ import annotations
import re
# Vocabulaire fortement lié au code / au développement.
_STRONG = re.compile(
r"\b(code|coder?|script|fonction|classe|refactor|bug|d[ée]bug|html|css|"
r"javascript|js|python|typescript|react|api|composant|page|site|landing|"
r"formulaire|d[ée]veloppe|impl[ée]mente|programme|widget|frontend|backend)\b",
re.I,
)
# Extensions de fichiers mentionnées explicitement.
_FILE_EXT = re.compile(
r"\.(py|js|ts|tsx|jsx|html?|css|json|md|sh|sql|ya?ml|go|rs|java|php|vue)\b", re.I
)
# Verbes d'action de création/modification.
_ACTION = re.compile(
r"\b(cr[ée]e[rs]?|modifie[rs]?|corrige[rs]?|ajoute[rs]?|[ée]cri[st]|refai[st]|"
r"am[ée]liore[rs]?|fixe?|update|change[rs]?|construis|g[ée]n[èe]re[rs]?)\b",
re.I,
)
def score_code_task(message: str) -> int:
"""Score heuristique : >= 3 -> code, <= 2 -> agent."""
score = 0
if _STRONG.search(message):
score += 2
if _FILE_EXT.search(message):
score += 2
if _ACTION.search(message):
score += 1
if "```" in message:
score += 2
return score
def is_code_task(message: str) -> bool:
"""Décision : heuristique pure, aucune requête modèle."""
return score_code_task(message) >= 3
# Formulations de « suite de travail » : courtes, sans vocabulaire code
# explicite, mais qui prolongent clairement la tâche en cours.
_FOLLOWUP = re.compile(
r"\b(continue[rs]?|reprend[s]?|poursui[st]|termine[rs]?|finis|"
r"rajoute[rs]?|enl[èe]ve[rs]?|retire[rs]?|supprime[rs]?|d[ée]place[rs]?|"
r"agrandi[st]|r[ée]dui[st]|remet[s]?|remplace[rs]?|inverse[rs]?|"
r"plut[ôo]t|aussi|encore|pareil|m[êe]me chose|comme avant)\b",
re.I,
)
def is_code_followup(message: str) -> bool:
"""Reprise probable d'un travail de code en cours.
À n'utiliser que si le tour précédent de la session était du code : un
« ajoute un bouton rouge » isolé n'est pas du code, mais après un
code_task, si.
"""
return bool(_FOLLOWUP.search(message)) or score_code_task(message) >= 1
View File
Whitespace-only changes.
-42
View File
@@ -1,42 +0,0 @@
"""Routes du benchmark de modèles."""
from __future__ import annotations
import json
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import bench
router = APIRouter(prefix="/api/bench", tags=["bench"])
class BenchRequest(BaseModel):
model: str
@router.get("")
async def scores() -> dict:
return {"scores": bench.get_scores()}
@router.post("")
async def run(req: BenchRequest) -> StreamingResponse:
async def event_stream():
try:
async for ev in bench.run_bench(req.model):
etype = ev.pop("type")
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
except Exception as exc:
payload = json.dumps(
{"message": f"benchmark interrompu : {str(exc)[:200]}"},
ensure_ascii=False,
)
yield f"event: error\ndata: {payload}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
)
-746
View File
@@ -1,746 +0,0 @@
"""Route de conversation : routage automatique agent / moteur code + SSE.
Flux :
1. on enregistre le message utilisateur ;
2. le routeur classe la demande : tâche de code -> moteur code (Aider),
sinon -> boucle agentique classique (qui peut elle-même appeler code_task) ;
3. on relaie tokens et événements d'outils au client (SSE) ;
4. on enregistre la réponse finale de l'assistant + le récap des outils.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from contextlib import suppress
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import (
agent_config, coder, db, enhance, memory, memory_notes, rag, skills, tools,
)
from .. import router as msg_router
from ..tools import check_html, _safe_path
from ..agent import run_agent
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api", tags=["chat"])
logger = logging.getLogger(__name__)
class ChatRequest(BaseModel):
session_id: str
content: str
model: str | None = None
# Mode d'exécution : "plan" (lecture seule), "build" (normal), "yolo" (auto).
mode: str = "build"
# Outils autorisés en mode Plan : lecture/analyse uniquement.
_READONLY_TOOLS = {"read_file", "list_dir", "grep_search", "run_check"}
def _apply_mode(cfg: dict, mode: str) -> dict:
"""Adapte la config au mode d'exécution choisi dans le composer."""
cfg = dict(cfg)
if mode == "plan":
# Lecture seule : aucune écriture, aucun shell, aucun moteur code.
cfg["tools"] = {
name: (on and name in _READONLY_TOOLS)
for name, on in cfg["tools"].items()
}
cfg["plan_mode"] = True
elif mode == "yolo":
# Autonomie maximale : plus de validation shell.
cfg["confirm_shell"] = False
# "build" : comportement par défaut (inchangé).
return cfg
async def _placement_notice(model: str) -> str | None:
"""Avertit si le modèle tourne (au moins en partie) hors du GPU.
C'est LA cause d'un débit ridicule (quelques jetons/s) : Ollama a placé
tout ou partie des couches en RAM. Loki ne peut pas le corriger — le
placement appartient à Ollama — mais il peut le NOMMER, au lieu de
laisser croire à un blocage de l'application.
"""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return None
for m in loaded:
if (m.get("name") or m.get("model")) != model:
continue
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0 or vram >= size * 0.99:
return None
pct = int(vram / size * 100)
in_ram = (size - vram) / 1e9
where = "entièrement en RAM" if pct == 0 else f"{pct} % en VRAM"
return (
f"⚠️ {model} tourne {where} ({in_ram:.1f} Go hors GPU) : "
"le débit sera de quelques jetons par seconde. Réduis le contexte, "
"prends un modèle plus petit, ou répartis sur tes GPU côté Ollama."
)
return None
def _loading_message(model: str, seconds: float) -> str:
"""Message d'attente avant le premier jeton (chargement du modèle)."""
waited = int(seconds)
delay = f"{waited // 60} min" if waited >= 60 else f"{waited} s"
text = f"Chargement de {model} en mémoire… ({delay})"
if waited >= 90:
# On CONSTATE l'attente sans présumer de la cause : selon la machine,
# un gros modèle peut se répartir sur plusieurs GPU, ou déborder sur
# le CPU. Le panneau Matériel donne le placement réel.
text += " — placement réel visible dans Réglages › Matériel."
return text
def _merge_system(convo: list[dict], extras: list[str]) -> None:
"""Garantit UN SEUL message système, en première position.
Beaucoup de templates Ollama (Gemma, Mistral…) contiennent un garde
« {% if role == 'system' and not loop.first %}{{ raise_exception(...) }} » :
tout message système qui n'est pas le premier fait échouer la requête
entière en 400 (« System message must be at the beginning »), y compris
la génération du parseur d'outils.
On fusionne donc, dans l'ordre : l'invite système, les éventuels systèmes
égarés (résumé de session…) puis les consignes du tour. Modifie ``convo``
sur place.
"""
head = ""
stray: list[str] = []
rest: list[dict] = []
for i, msg in enumerate(convo):
if msg.get("role") == "system":
text = (msg.get("content") or "").strip()
if i == 0:
head = text
elif text:
stray.append(text)
else:
rest.append(msg)
blocks = [b for b in (head, *stray, *(e.strip() for e in extras if e)) if b]
convo[:] = ([{"role": "system", "content": "\n\n".join(blocks)}] if blocks else []) + rest
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _prev_was_code(history: list[dict]) -> bool:
"""Le dernier tour assistant de la session était-il un travail de code ?"""
for m in reversed(history):
if m["role"] != "assistant":
continue
meta = m.get("meta") or {}
if meta.get("engine") == "code":
return True
tools = meta.get("tools") or []
return any(
t.get("name") in ("code_task", "write_file", "edit_file")
for t in tools
)
return False
def _session_code_context(history: list[dict]) -> tuple[str, list[str]]:
"""Récap compact du travail en cours + fichiers touchés dans la session.
Le moteur code ne reçoit que le message courant : sur une reprise
(« corrige les bugs »), sans ce récap il ignore quel fichier, quel projet
et quelle demande d'origine. Les fichiers touchés servent aussi de cible
par défaut pour Aider.
"""
root = tools.active_root()
files: list[str] = []
for m in history:
if m["role"] != "assistant":
continue
for t in (m.get("meta") or {}).get("tools") or []:
candidates: list[str] = []
path = (t.get("args") or {}).get("path")
if t.get("name") in ("write_file", "edit_file") and path:
candidates.append(str(path))
for f in t.get("files") or []:
candidates.append(str(f))
for c in candidates:
rel = c.replace("\\", "/").lstrip("./")
if rel not in files and os.path.isfile(os.path.join(root, rel)):
files.append(rel)
user_msgs = [m["content"].strip() for m in history if m["role"] == "user"]
lines: list[str] = []
if user_msgs:
lines.append(f"- Demande initiale : {user_msgs[0][:200]}")
for prev in user_msgs[-2:]:
if prev != user_msgs[0]:
lines.append(f"- Puis : {prev[:200]}")
if files:
lines.append(f"- Fichiers déjà créés/modifiés : {', '.join(files[:8])}")
recap = (
"Contexte de la session (travail en cours) :\n" + "\n".join(lines)
if lines else ""
)
return recap, files
def _workspace_listing(limit: int = 40) -> list[str]:
"""Chemins relatifs des fichiers de la racine active (aperçu compact)."""
root = tools.active_root()
out: list[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for name in sorted(filenames):
if name.startswith("."):
continue
rel = os.path.relpath(os.path.join(dirpath, name), root)
out.append(rel.replace("\\", "/"))
if len(out) >= limit:
return out
return out
_FILE_MENTION = re.compile(r"[\w][\w./\\-]*\.[a-z0-9]{1,5}\b", re.I)
def _mentioned_files(text: str) -> list[str]:
"""Fichiers du workspace explicitement cités dans le message.
Transmis au moteur code pour qu'Aider travaille directement sur les bons
fichiers au lieu de deviner via la repo map.
"""
root = tools.active_root()
out: list[str] = []
for raw in _FILE_MENTION.findall(text):
rel = raw.replace("\\", "/").lstrip("./")
if os.path.isfile(os.path.join(root, rel)) and rel not in out:
out.append(rel)
return out[:8]
async def _run_aider_keepalive(
instruction: str, model: str, files: list[str] | None = None
):
"""Lance Aider dans un thread en gardant le flux SSE vivant."""
# Racine résolue AVANT le thread : la contextvar projet ne suit pas
# dans asyncio.to_thread.
root = tools.active_root()
task = asyncio.create_task(
asyncio.to_thread(coder.run_code_task, instruction, model, files, root)
)
while not task.done():
await asyncio.sleep(10)
if not task.done():
yield None # signal keepalive
yield await task
async def _code_stream(
req: ChatRequest,
model: str,
*,
extra: str = "",
plan: list[str] | None = None,
files: list[str] | None = None,
):
"""Chemin « moteur code » : Aider + vérification HTML avec auto-correction."""
instruction = req.content + (extra or "")
yield _sse("tool_call", {"name": "code_task", "args": {"instruction": req.content}})
result = None
async for item in _run_aider_keepalive(instruction, model, files):
if item is None:
yield ": keepalive\n\n"
else:
result = item
status = "ok" if result.get("ok") else "error"
record = {
"name": "code_task",
"args": {"instruction": req.content},
"summary": result.get("summary", "terminé"),
"status": status,
}
tools_meta = [record]
yield _sse("tool_result", record)
all_files = list(result.get("files") or [])
# Vérification des pages HTML produites + une passe d'auto-correction.
html_issues: list[str] = []
for f in all_files:
if f.lower().endswith((".html", ".htm")):
try:
issues = check_html(_safe_path(f))
except Exception:
issues = []
if issues:
html_issues.append(f"{f} : " + " ; ".join(issues))
if html_issues and result.get("ok"):
yield _sse("tool_call", {"name": "html_check", "args": {"path": "vérification"}})
yield _sse("tool_result", {
"name": "html_check", "args": {"path": "vérification"},
"summary": " | ".join(html_issues)[:200], "status": "error",
})
tools_meta.append({
"name": "html_check", "args": {},
"summary": " | ".join(html_issues)[:200], "status": "error",
})
fix_instruction = (
"Corrige ces problèmes détectés dans les fichiers HTML, sans rien "
"casser d'autre :\n" + "\n".join(html_issues)
)
fix = None
async for item in _run_aider_keepalive(fix_instruction, model):
if item is None:
yield ": keepalive\n\n"
else:
fix = item
fix_rec = {
"name": "code_task",
"args": {"instruction": "auto-correction HTML"},
"summary": fix.get("summary", "terminé"),
"status": "ok" if fix.get("ok") else "error",
}
tools_meta.append(fix_rec)
yield _sse("tool_call", {"name": "code_task", "args": fix_rec["args"]})
yield _sse("tool_result", fix_rec)
for f in fix.get("files") or []:
if f not in all_files:
all_files.append(f)
if fix.get("text"):
result["text"] = (result.get("text") or "") + "\n\n" + fix["text"]
# Cartes par fichier modifié (réutilise le rendu write_file de l'UI).
for f in all_files:
file_rec = {
"name": "write_file",
"args": {"path": f},
"summary": "modifié par le moteur code",
"status": "ok",
}
tools_meta.append(file_rec)
yield _sse("tool_call", {"name": "write_file", "args": {"path": f}})
yield _sse("tool_result", file_rec)
text = result.get("text") or (
"" if result.get("ok") else f"⚠️ {result.get('summary', 'échec du moteur code')}"
)
if text:
yield _sse("token", {"content": text})
meta: dict = {"tools": tools_meta, "engine": "code"}
if plan:
meta["plan"] = plan
db.add_message(req.session_id, "assistant", text, model, meta=meta)
yield _sse("done", {"content": text, "tools": tools_meta})
@router.post("/chat")
async def chat(req: ChatRequest) -> StreamingResponse:
session = db.get_session(req.session_id)
if not session:
raise HTTPException(404, "session introuvable")
model = req.model or session.get("model") or settings.default_model
cfg = _apply_mode(agent_config.get_config(model), req.mode)
# Projet de la session : re-racine outils, shell, Aider et aides de
# contexte pour tout le tour. Projet disparu -> retour racine + notice.
project = session.get("project") or None
project_missing = False
if project:
proj_dir = os.path.join(os.path.abspath(settings.workspace_dir), project)
if not os.path.isdir(proj_dir):
project_missing, project = True, None
tools.set_project(project)
history = db.list_messages(req.session_id)
# Premier message : titre la session avec un extrait.
if not history:
title = req.content.strip().split("\n")[0][:60] or "Nouvelle session"
db.rename_session(req.session_id, title)
db.add_message(req.session_id, "user", req.content, None)
# Routage automatique : moteur code si la demande est une tâche de code —
# ou la SUITE d'un travail de code (« ajoute un bouton », « continue »…),
# que l'heuristique seule classerait à tort en discussion.
prev_code = _prev_was_code(history)
use_code = (
cfg["tools"].get("code_task", True)
and coder.available()
and (
msg_router.is_code_task(req.content)
or (prev_code and msg_router.is_code_followup(req.content))
)
)
# Mémoire compressée : système + résumé des anciens tours + messages récents.
convo = memory.build_convo(req.session_id, cfg["system_prompt"])
# Options runner partagées par TOUS les appels au modèle de chat (plan,
# résumé, agent) : indispensables pour qu'Ollama garde le même runner.
run_opts = agent_config.runner_options(cfg)
keep = cfg.get("keep_alive", "30m")
async def event_stream():
yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
if project_missing:
yield _sse("notice", {"message": (
"Projet de la session introuvable sur le disque — retour au "
"workspace."
)})
# Préparation du contexte APRÈS le start SSE et en PARALLÈLE : rappel
# RAG, plan et choix du modèle code partent ensemble au lieu de
# s'enchaîner en bloquant le premier token.
want_rag = cfg.get("rag_enabled", False)
# Un plan « from scratch » n'a de sens que pour une NOUVELLE construction.
# Pour une correction ou la suite d'un travail (l'appli existe déjà,
# session code en cours, formulation de suivi), il est hors-sujet et
# perturbe : on ne replanifie pas l'architecture à chaque message.
is_modification = prev_code or bool(_workspace_listing())
new_build = use_code and not is_modification
want_plan = cfg.get("plan_mode", True) and (
new_build or (not use_code and enhance.needs_plan(req.content))
)
if want_rag or want_plan or use_code:
yield _sse("status", {"message": "Préparation du contexte…"})
from ..mcp_client import manager as mcp_manager
# La préparation peut déclencher un CHARGEMENT de modèle (plan, embed)
# qui dure plusieurs minutes sur un gros modèle. Sans battement de
# cœur, le silence SSE fait couper la connexion par le reverse proxy.
prep = asyncio.ensure_future(asyncio.gather(
rag.recall(req.session_id, req.content, embed_model=cfg.get("embed_model"))
if want_rag else asyncio.sleep(0, result=[]),
enhance.make_plan(model, req.content, code=use_code,
options=run_opts, keep_alive=keep)
if want_plan else asyncio.sleep(0, result=[]),
coder.pick_code_model(model, cfg.get("code_model"))
if use_code else asyncio.sleep(0, result=model),
mcp_manager.tool_definitions(),
))
while True:
try:
memories, plan, code_model, mcp_tools = await asyncio.wait_for(
asyncio.shield(prep), timeout=10.0
)
break
except TimeoutError:
yield _sse("ping", {"status": "waiting"})
# Pannes MCP éventuelles : notice non bloquante dans le fil.
for mcp_notice in mcp_manager.notices():
yield _sse("notice", {"message": mcp_notice})
# Placement du modèle : dire tout de suite si l'inférence est hors GPU,
# au lieu de laisser l'utilisateur attribuer la lenteur à Loki.
placement = await _placement_notice(model)
if placement:
yield _sse("notice", {"message": placement})
# ── Consignes additionnelles du tour ──────────────────────────────
# Elles sont COLLECTÉES ici puis fusionnées dans l'UNIQUE message
# système (voir _merge_system plus bas). Beaucoup de templates
# (Gemma, Mistral…) lèvent « System message must be at the beginning »
# dès qu'un second message system apparaît : les empiler faisait
# échouer toute la requête en 400.
extras: list[str] = []
# La demande touche-t-elle au code / aux fichiers ? Calculé AVANT les
# injections : tout ce qui suit est conditionné à cette réponse.
is_code_like = use_code or msg_router.is_code_task(req.content)
needs_workspace = is_code_like or prev_code
if memories:
extras.append(
"Souvenirs pertinents d'anciennes sessions :\n"
+ "\n---\n".join(memories)
)
# État du workspace : indispensable pour travailler sur les fichiers,
# mais inutile pour une simple discussion. On l'injectait à CHAQUE
# message — un « bonjour » partait avec des milliers de jetons de
# contexte, donc une longue phase de traitement avant le 1er jeton.
listing = _workspace_listing() if needs_workspace else []
if listing:
recap, session_files = _session_code_context(history)
parts = ["Fichiers du workspace : " + ", ".join(listing)]
if session_files:
parts.append(
"Fichiers de la tâche en cours : " + ", ".join(session_files[:8])
)
extras.append("\n".join(parts))
# Session code restée en chemin agent : pousse le modèle à AGIR sur
# les fichiers au lieu de décrire les changements — cause fréquente de
# « l'agent s'arrête sans rien modifier » sur une reprise de code.
if prev_code and not use_code:
extras.append(
"Cette session travaille sur du code existant du workspace. "
"Pour toute demande de modification ou d'ajout : AGIS avec "
"les outils — code_task pour un changement multi-fichiers, "
"edit_file pour un changement ciblé, write_file pour un "
"nouveau fichier. Lis le fichier concerné avant de le "
"modifier, puis modifie-le RÉELLEMENT. Ne colle JAMAIS le "
"code corrigé dans ta réponse sans l'avoir écrit dans le "
"fichier."
)
# Skill : méthode experte injectée pour ce tour (jamais persistée).
if cfg.get("skills_enabled", True):
skill = skills.pick_skill(req.content)
if skill:
extras.append("Méthode à suivre pour cette tâche :\n" + skill["body"])
yield _sse("notice", {"message": f"📘 Méthode : {skill['title']}"})
# Ponytail : méthode « code minimal » injectée pour toute tâche de code
# (les deux chemins). Contre la sur-ingénierie qui casse les rendus.
if cfg.get("ponytail", True) and is_code_like:
extras.append(skills.PONYTAIL_GUIDANCE)
# Appli web : contraintes dures (fichier autonome, zéro dépendance
# externe, rendu réel) pour que ça marche vraiment hors-ligne.
want_web = is_code_like and skills.is_web_task(req.content)
if want_web:
extras.append(skills.WEBAPP_GUIDANCE)
# Mémoire en notes : en mode « always », on injecte les notes liées à la
# demande. Rien n'est deviné — ce sont des notes que l'agent a
# lui-même écrites, et le bloc dit explicitement de les ignorer si
# elles ne s'appliquent pas.
memory_mode = cfg.get("memory_mode", "ondemand")
# Mode off : les outils n'existent pas pour le modèle (principe AJEAN).
# En mode Plan (lecture seule), la consultation reste permise mais pas
# l'écriture d'une note.
memory_tools = (
[] if memory_mode == "off"
else ["memory_search"] if req.mode == "plan"
else ["memory_search", "memory_save"]
)
if memory_mode == "always":
block = memory_notes.recall_block(req.content)
if block:
extras.append(block)
if plan:
yield _sse("plan", {"steps": plan})
# Chemin « moteur code » : Aider gère la tâche de bout en bout.
if use_code:
instruction_plan = (
"\n\nPlan à suivre :\n"
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
if plan else ""
)
# Reprise : Aider ne voit que le message courant — on lui donne le
# récap de session et, à défaut de fichiers cités, ceux déjà
# touchés (« corrige les bugs » => il ouvre le bon fichier).
recap, session_files = _session_code_context(history)
extra = instruction_plan
if recap:
extra = f"\n\n{recap}" + extra
# Le moteur code (Aider) ne voit pas convo : on lui redonne Ponytail
# (et les contraintes web) directement dans la consigne.
if want_web:
extra = "\n\n" + skills.WEBAPP_GUIDANCE + extra
if cfg.get("ponytail", True):
extra = "\n\n" + skills.PONYTAIL_GUIDANCE + extra
code_files = _mentioned_files(req.content) or session_files[:8]
async for chunk in _code_stream(
req, code_model, extra=extra, plan=plan, files=code_files,
):
yield chunk
asyncio.create_task(memory.maybe_summarize(
req.session_id, model, options=run_opts, keep_alive=keep,
))
if cfg.get("rag_enabled", False):
last = db.list_messages(req.session_id)
answer = last[-1]["content"] if last else ""
asyncio.create_task(rag.index_exchange(
req.session_id, req.content, answer,
embed_model=cfg.get("embed_model"),
))
return
if plan:
extras.append(
"Plan à suivre pour cette demande, étape par étape :\n"
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
+ "\n\nTraite les étapes DANS L'ORDRE. Dès qu'une étape est "
"réellement accomplie, écris sur une ligne seule "
"« ✅ Étape N terminée » (N = son numéro) avant de passer à "
"la suivante. N'annonce jamais une étape terminée à l'avance."
)
# Fusion : un SEUL message système, en tête. Indispensable pour les
# templates qui refusent tout system ailleurs qu'en première position.
_merge_system(convo, extras)
final_content = ""
tools_meta: list[dict] = []
stats_meta: dict | None = None
thinking_meta = ""
error_message = ""
queue: asyncio.Queue[dict | None] = asyncio.Queue()
async def produce_events() -> None:
try:
async for event in run_agent(
model,
convo,
options=agent_config.ollama_options(cfg),
enabled_tools=agent_config.enabled_tool_names(cfg) + memory_tools,
confirm_shell=cfg.get("confirm_shell", True),
think=cfg.get("think", True),
keep_alive=cfg.get("keep_alive", "30m"),
mcp_tools=mcp_tools,
plan=plan,
):
await queue.put(event)
except Exception as exc:
logger.exception("Échec inattendu du flux de chat")
await queue.put(
{
"type": "error",
"message": f"Erreur interne du chat : {exc}",
}
)
finally:
await queue.put(None)
producer = asyncio.create_task(produce_events())
started = time.monotonic()
got_event = False
try:
while True:
try:
ev = await asyncio.wait_for(queue.get(), timeout=15.0)
except TimeoutError:
# Empêche OpenResty/Nginx/Cloudflare de fermer le SSE pendant
# le chargement parfois long d'un modèle Ollama.
yield _sse("ping", {"status": "waiting"})
# …et DIT ce qui se passe : sans ça, l'interface restait sur
# « Connexion à Ollama… » pendant tout le chargement d'un
# gros modèle, sans moyen de distinguer lenteur et blocage.
if not got_event:
yield _sse("status", {
"message": _loading_message(
model, time.monotonic() - started
)
})
continue
got_event = True
if ev is None:
break
etype = ev.pop("type")
if etype in (
"token",
"thinking",
"status",
"notice",
"tool_call",
"tool_result",
"tool_confirm",
"plan_step",
):
yield _sse(etype, ev)
elif etype == "error":
error_message = ev.get("message", "Erreur Ollama inconnue")
yield _sse("error", {"message": error_message})
elif etype == "final":
final_content = ev["content"]
tools_meta = ev["tools"]
stats_meta = ev.get("stats")
thinking_meta = ev.get("thinking", "")
finally:
if not producer.done():
producer.cancel()
with suppress(asyncio.CancelledError):
await producer
# Auto-critique : relecture éclair puis révision (option « Qualité + »).
if (
cfg.get("self_review", False)
and final_content
and not error_message
and not tools_meta
):
yield _sse("status", {"message": "Relecture de la réponse…"})
revised = await enhance.self_review(
model, req.content, final_content,
options=run_opts, keep_alive=keep,
)
if revised:
final_content = revised
yield _sse("revision", {"content": revised})
yield _sse("notice", {"message": "Réponse révisée après auto-critique ✓"})
if final_content or tools_meta:
meta: dict = {}
if tools_meta:
meta["tools"] = tools_meta
if stats_meta:
meta["stats"] = stats_meta
if thinking_meta:
meta["thinking"] = thinking_meta
if plan:
meta["plan"] = plan
db.add_message(
req.session_id,
"assistant",
final_content,
model,
meta=meta or None,
)
yield _sse(
"done",
{
"content": final_content,
"tools": tools_meta,
"stats": stats_meta,
"error": error_message or None,
},
)
# Tâches d'arrière-plan : compression de l'historique + mémoire RAG.
asyncio.create_task(memory.maybe_summarize(
req.session_id, model, options=run_opts, keep_alive=keep,
))
if cfg.get("rag_enabled", False) and final_content:
asyncio.create_task(rag.index_exchange(
req.session_id, req.content, final_content,
embed_model=cfg.get("embed_model"),
))
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
)
-86
View File
@@ -1,86 +0,0 @@
"""Routes de lecture et mise à jour de la configuration de l'agent."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import agent_config
router = APIRouter(prefix="/api/config", tags=["config"])
class ConfigPatch(BaseModel):
"""Champs modifiables. DOIT couvrir agent_config.PROFILE_FIELDS.
Pydantic ignore silencieusement un champ non déclaré : un oubli ici fait
« sauter » le réglage sans la moindre erreur (c'était le cas de
keep_alive, skills_enabled et ponytail).
"""
system_prompt: str | None = None
temperature: float | None = None
top_p: float | None = None
top_k: int | None = None
max_tokens: int | None = None
num_ctx: int | None = None
num_gpu: int | None = None
num_batch: int | None = None
tools: dict[str, bool] | None = None
confirm_shell: bool | None = None
think: bool | None = None
code_model: str | None = None
plan_mode: bool | None = None
self_review: bool | None = None
rag_enabled: bool | None = None
embed_model: str | None = None
memory_mode: str | None = None
skills_enabled: bool | None = None
ponytail: bool | None = None
keep_alive: str | None = None
@router.get("")
async def get_config(model: str | None = None) -> dict:
return {
"config": agent_config.get_config(model),
"available_tools": agent_config.AVAILABLE_TOOLS,
}
@router.put("")
async def put_config(patch: ConfigPatch, model: str | None = None) -> dict:
cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model)
return {"config": cfg}
# ── Presets : jeux de réglages nommés, commutables (idée reprise d'AJEAN) ──
class PresetBody(BaseModel):
name: str
@router.get("/presets")
async def get_presets() -> dict:
return {"presets": agent_config.list_presets()}
@router.post("/presets")
async def post_preset(body: PresetBody, model: str | None = None) -> dict:
"""Enregistre la configuration courante sous un nom."""
try:
return {"presets": agent_config.save_preset(body.name, model)}
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/presets/apply")
async def apply_preset(body: PresetBody, model: str | None = None) -> dict:
"""Applique un preset à la configuration courante."""
cfg = agent_config.apply_preset(body.name, model)
if cfg is None:
raise HTTPException(404, "preset introuvable")
return {"config": cfg}
@router.delete("/presets")
async def remove_preset(name: str) -> dict:
return {"presets": agent_config.delete_preset(name)}
-105
View File
@@ -1,105 +0,0 @@
"""Routes du workspace : arborescence, contenu, téléchargement, suppression.
Toutes les routes acceptent un paramètre optionnel ``project`` : la requête
est alors re-racinée sur ``workspace/<projet>`` (même confinement).
"""
from __future__ import annotations
import os
import shutil
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from .. import tools as tools_mod
from ..tools import ToolError, _safe_path
router = APIRouter(prefix="/api/files", tags=["files"])
def _activate(project: str | None) -> None:
"""Pose le projet actif pour la requête (400 si nom invalide)."""
try:
tools_mod.set_project(project or None)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
def _tree(path: str, root: str) -> list[dict]:
"""Arborescence triée (dossiers d'abord) de la racine active."""
items = []
for name in sorted(os.listdir(path)):
if name.startswith("."):
continue
full = os.path.join(path, name)
rel = os.path.relpath(full, root)
if os.path.isdir(full):
items.append({"name": name, "path": rel, "type": "dir",
"children": _tree(full, root)})
else:
items.append({"name": name, "path": rel, "type": "file",
"size": os.path.getsize(full)})
# Dossiers avant fichiers
items.sort(key=lambda x: (x["type"] != "dir", x["name"]))
return items
@router.get("")
async def list_files(project: str | None = None) -> dict:
_activate(project)
root = tools_mod.active_root()
return {"tree": _tree(root, root)}
@router.get("/content")
async def file_content(path: str, project: str | None = None) -> dict:
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc))
if not os.path.isfile(target):
raise HTTPException(404, "fichier introuvable")
with open(target, "r", encoding="utf-8", errors="replace") as f:
return {"path": path, "content": f.read()}
@router.get("/download")
async def download_file(path: str, project: str | None = None) -> FileResponse:
"""Télécharge un fichier en conservant le confinement au workspace."""
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
if not os.path.isfile(target):
raise HTTPException(404, "fichier introuvable")
return FileResponse(
target,
filename=os.path.basename(target),
media_type="application/octet-stream",
)
@router.delete("")
async def delete_file(path: str, project: str | None = None) -> dict:
"""Supprime un fichier ou un dossier (récursif) de la racine active.
Même confinement que le téléchargement (`_safe_path`) ; la racine active
est refusée. Les dotfiles (.git…) ne sont jamais listés par
l'arborescence, donc inaccessibles depuis l'UI.
"""
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
if os.path.abspath(target) == tools_mod.active_root():
raise HTTPException(400, "suppression de la racine refusée")
if os.path.isdir(target):
shutil.rmtree(target)
elif os.path.isfile(target):
os.remove(target)
else:
raise HTTPException(404, "fichier introuvable")
return {"deleted": path}
-106
View File
@@ -1,106 +0,0 @@
"""Routes Git du workspace : historique, diff, retour arrière.
Le workspace est un dépôt git (Aider commite chaque modification). Ces routes
donnent à l'UI un panneau Git : voir les commits, leur diff, et annuler une
modification. Toutes les commandes sont exécutées DANS le workspace.
"""
from __future__ import annotations
import os
import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import tools
from ..coder import ensure_git
from ..config import settings
router = APIRouter(prefix="/api/git", tags=["git"])
def _git(
*args: str, timeout: int = 15, project: str | None = None
) -> subprocess.CompletedProcess:
"""Exécute une commande git dans le workspace ou le projet demandé."""
root = os.path.abspath(settings.workspace_dir)
if project:
if not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
root = os.path.join(root, project)
ensure_git(root)
return subprocess.run(
["git", *args],
cwd=root,
capture_output=True,
text=True,
timeout=timeout,
)
@router.get("/log")
async def git_log(limit: int = 40, project: str | None = None) -> dict:
"""Historique des commits (hash court, message, auteur, date, nb fichiers)."""
fmt = "%h%x1f%s%x1f%an%x1f%ar%x1f%H"
proc = _git(
"log", f"-{max(1, min(limit, 200))}", f"--pretty=format:{fmt}",
project=project,
)
if proc.returncode != 0:
# Dépôt sans commit encore.
return {"commits": []}
commits = []
for line in proc.stdout.splitlines():
parts = line.split("\x1f")
if len(parts) != 5:
continue
short, subject, author, when, full = parts
# Nombre de fichiers touchés par ce commit.
stat = _git("show", "--stat", "--oneline", "--format=", full,
project=project)
files = [l for l in stat.stdout.splitlines() if "|" in l]
commits.append({
"hash": short,
"full_hash": full,
"subject": subject,
"author": author,
"when": when,
"files_changed": len(files),
})
return {"commits": commits}
@router.get("/diff")
async def git_diff(hash: str | None = None, project: str | None = None) -> dict:
"""Diff d'un commit (hash) ou des modifications non commitées si absent."""
if hash:
if not hash.replace("-", "").isalnum():
raise HTTPException(400, "hash invalide")
proc = _git("show", "--no-color", hash, project=project)
else:
proc = _git("diff", "--no-color", "HEAD", project=project)
if proc.returncode != 0:
raise HTTPException(404, "diff introuvable")
# Borne la taille pour ne pas noyer l'UI.
return {"diff": proc.stdout[:200_000]}
class RevertRequest(BaseModel):
hash: str
project: str | None = None
@router.post("/revert")
async def git_revert(req: RevertRequest) -> dict:
"""Annule un commit en créant un commit inverse (git revert)."""
h = req.hash.strip()
if not h.replace("-", "").isalnum():
raise HTTPException(400, "hash invalide")
proc = _git("revert", "--no-edit", h, timeout=30, project=req.project)
if proc.returncode != 0:
# Conflit ou commit introuvable : on nettoie un éventuel revert en cours.
_git("revert", "--abort", project=req.project)
detail = (proc.stderr or proc.stdout).strip()[:300]
raise HTTPException(409, f"revert impossible : {detail}")
return {"ok": True, "message": f"commit {h[:7]} annulé"}
-54
View File
@@ -1,54 +0,0 @@
"""Routes de configuration des serveurs MCP (catalogue + toggles)."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..mcp_client import CATALOG, get_mcp_state, manager, set_mcp_state
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def _payload() -> dict:
state = get_mcp_state()
statuses = manager.statuses()
return {
"servers": [
{
"id": sid,
"label": entry["label"],
"description": entry["description"],
"url_param": entry["url_param"],
"env_params": entry["env_params"],
"enabled": state[sid]["enabled"],
"params": state[sid]["params"],
**statuses[sid],
}
for sid, entry in CATALOG.items()
]
}
@router.get("")
async def list_servers() -> dict:
return _payload()
class McpUpdate(BaseModel):
enabled: bool
params: dict = {}
@router.put("/{sid}")
async def update_server(sid: str, req: McpUpdate) -> dict:
if sid not in CATALOG:
raise HTTPException(404, "serveur inconnu")
set_mcp_state(sid, enabled=req.enabled, params=req.params)
return _payload()
@router.post("/{sid}/test")
async def test_server(sid: str) -> dict:
if sid not in CATALOG:
raise HTTPException(404, "serveur inconnu")
return await manager.test_server(sid)
-197
View File
@@ -1,197 +0,0 @@
"""Routes liées à Ollama : statut de connexion, modèles, téléchargement."""
from __future__ import annotations
import asyncio
import json
import httpx
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import agent_config
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api", tags=["ollama"])
# État en mémoire des préchargements. Loki est lancé avec un worker unique ; ce
# suivi permet au navigateur d'interroger une route courte pendant que le long
# chargement Ollama continue sans maintenir la requête HTTP initiale ouverte.
_warm_states: dict[str, dict[str, str]] = {}
_warm_tasks: dict[str, asyncio.Task[None]] = {}
@router.get("/status")
async def status() -> dict:
"""État de la connexion Ollama (point vert/rouge de la barre supérieure)."""
try:
version = await ollama.ping()
return {
"connected": True,
"host": ollama.host,
"version": version.get("version"),
"default_model": settings.default_model,
}
except (httpx.HTTPError, OSError) as exc:
return {
"connected": False,
"host": ollama.host,
"error": str(exc),
"default_model": settings.default_model,
}
@router.get("/models")
async def list_models() -> dict:
"""Modèles installés localement, formatés pour le sélecteur de l'UI."""
try:
raw = await ollama.list_models_cached()
except (httpx.HTTPError, OSError):
return {"models": []}
models = []
for m in raw:
details = m.get("details", {})
size_go = round(m.get("size", 0) / 1_000_000_000, 1)
models.append(
{
"name": m.get("name"),
"size_go": size_go,
"parameter_size": details.get("parameter_size"),
"quantization": details.get("quantization_level"),
"family": details.get("family"),
}
)
return {"models": models, "default": settings.default_model}
class WarmRequest(BaseModel):
name: str
keep_alive: str = "30m"
async def _placement_of(name: str) -> dict:
"""Où le modèle vient-il d'être chargé ? (GPU / CPU / mixte), via /api/ps."""
try:
for m in await ollama.ps():
if m.get("name") == name or m.get("model") == name:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0:
return {}
pct = int(vram / size * 100)
where = "gpu" if pct >= 99 else "cpu" if pct <= 1 else "mixte"
return {"processor": where, "gpu_percent": str(pct)}
except (httpx.HTTPError, OSError):
pass
return {}
async def _warm_in_background(name: str, keep_alive: str, options: dict | None) -> None:
try:
await ollama.warm(name, keep_alive, options)
state = {"state": "loaded"}
state.update(await _placement_of(name))
_warm_states[name] = state
except Exception as exc:
_warm_states[name] = {
"state": "error",
"error": f"préchargement impossible : {str(exc)[:500]}",
}
def start_model_warm(name: str, keep_alive: str) -> None:
"""Démarre au plus une tâche de préchargement par modèle.
Les options de génération du modèle (num_ctx, num_batch, num_gpu…) sont
envoyées au préchargement pour qu'Ollama charge exactement le runner que le
chat utilisera — évite un rechargement complet au premier message.
"""
current = _warm_tasks.get(name)
if current and not current.done():
return
cfg = agent_config.get_config(name)
options = agent_config.ollama_options(cfg)
_warm_states[name] = {"state": "loading"}
task = asyncio.create_task(_warm_in_background(name, keep_alive, options))
_warm_tasks[name] = task
def forget(done: asyncio.Task[None]) -> None:
if _warm_tasks.get(name) is done:
_warm_tasks.pop(name, None)
task.add_done_callback(forget)
@router.post("/models/warm", status_code=202)
async def warm_model(req: WarmRequest) -> dict:
"""Démarre le préchargement sans exposer sa durée au reverse proxy."""
name = req.name.strip()
if not name:
raise HTTPException(400, "nom de modèle vide")
start_model_warm(name, req.keep_alive)
return {"warming": name, "state": _warm_states[name]["state"]}
@router.get("/models/warm/status")
async def warm_status(name: str = Query(min_length=1)) -> dict:
"""État court du préchargement : idle, loading, loaded ou error."""
return _warm_states.get(name.strip(), {"state": "idle"})
@router.get("/models/loaded")
async def loaded_models() -> dict:
"""Modèles actuellement chargés en mémoire + placement GPU/CPU (/api/ps)."""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return {"loaded": []}
result = []
for m in loaded:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
result.append({
"name": m.get("name") or m.get("model"),
"on_gpu": bool(size and vram >= size * 0.99),
"gpu_percent": int(vram / size * 100) if size else 0,
})
return {"loaded": result}
class PullRequest(BaseModel):
name: str
class DeleteModelRequest(BaseModel):
name: str
@router.delete("/models")
async def delete_model(req: DeleteModelRequest) -> dict:
"""Supprime explicitement un modèle de l'instance Ollama."""
if not req.name.strip():
raise HTTPException(400, "nom de modèle vide")
try:
await ollama.delete_model(req.name.strip())
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:500] or str(exc)
raise HTTPException(502, f"Ollama : {detail}") from exc
except (httpx.HTTPError, OSError) as exc:
raise HTTPException(502, f"Ollama injoignable : {exc}") from exc
return {"deleted": req.name.strip()}
@router.post("/models/pull")
async def pull_model(req: PullRequest) -> StreamingResponse:
"""Télécharge un modèle en streamant la progression (SSE)."""
async def event_stream():
try:
async for chunk in ollama.pull_model(req.name):
yield f"data: {json.dumps(chunk)}\n\n"
except (httpx.HTTPError, OSError) as exc:
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
-60
View File
@@ -1,60 +0,0 @@
"""Routes des projets : sous-dossiers de premier niveau du workspace."""
from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import coder, tools
from ..config import settings
router = APIRouter(prefix="/api/projects", tags=["projects"])
def _root() -> str:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return root
def _count_files(path: str) -> int:
total = 0
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
total += sum(1 for f in filenames if not f.startswith("."))
return total
@router.get("")
async def list_projects() -> dict:
root = _root()
projects = []
root_files = 0
for name in sorted(os.listdir(root)):
full = os.path.join(root, name)
if name.startswith("."):
continue
if os.path.isdir(full):
projects.append({"name": name, "files": _count_files(full)})
else:
root_files += 1
return {"projects": projects, "root_files": root_files}
class CreateProject(BaseModel):
name: str
@router.post("", status_code=201)
async def create_project(req: CreateProject) -> dict:
name = req.name.strip()
if not tools.PROJECT_NAME.match(name):
raise HTTPException(400, "nom de projet invalide (a-z, 0-9, - et _)")
target = os.path.join(_root(), name)
if os.path.exists(target):
raise HTTPException(400, "ce projet existe déjà")
os.makedirs(target)
# Dépôt git par projet : commits Aider + onglet Git propres au projet.
coder.ensure_git(target)
return {"name": name}
-60
View File
@@ -1,60 +0,0 @@
"""Routes de gestion des sessions de conversation."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import db, tools
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
class CreateSession(BaseModel):
title: str = "Nouvelle session"
model: str | None = None
project: str | None = None
class UpdateSession(BaseModel):
title: str | None = None
project: str | None = None # "" = retour à la racine du workspace
@router.get("")
async def get_sessions() -> dict:
return {"sessions": db.list_sessions()}
@router.post("")
async def post_session(req: CreateSession) -> dict:
if req.project and not tools.PROJECT_NAME.match(req.project):
raise HTTPException(400, "nom de projet invalide")
return db.create_session(req.title, req.model, req.project or None)
@router.get("/{sid}")
async def get_one(sid: str) -> dict:
session = db.get_session(sid)
if not session:
raise HTTPException(404, "session introuvable")
return {"session": session, "messages": db.list_messages(sid)}
@router.patch("/{sid}")
async def patch_session(sid: str, req: UpdateSession) -> dict:
if not db.get_session(sid):
raise HTTPException(404, "session introuvable")
if req.title is not None:
db.rename_session(sid, req.title)
if req.project is not None:
project = req.project or None
if project and not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
db.set_session_project(sid, project)
return {"ok": True}
@router.delete("/{sid}")
async def remove_session(sid: str) -> dict:
db.delete_session(sid)
return {"ok": True}
-33
View File
@@ -1,33 +0,0 @@
"""Route d'exécution d'une commande shell validée par l'utilisateur.
La boucle agentique n'exécute jamais run_shell elle-même quand la confirmation
est active : elle émet un événement `tool_confirm`. Le client affiche la
commande, et c'est seulement après clic explicite de l'utilisateur que cette
route exécute la commande (confinée au workspace).
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..tools import ToolError, run_shell
router = APIRouter(prefix="/api/shell", tags=["shell"])
class ShellRequest(BaseModel):
command: str
@router.post("/run")
async def run(req: ShellRequest) -> dict:
"""Exécute la commande validée et renvoie sa sortie."""
try:
result = run_shell(req.command)
except ToolError as exc:
raise HTTPException(400, str(exc))
return {
"command": req.command,
"exit_code": result["exit_code"],
"output": result["output"],
}
-232
View File
@@ -1,232 +0,0 @@
"""Statistiques système temps réel : CPU, RAM, GPU/VRAM (barre supérieure)."""
from __future__ import annotations
import asyncio
import shutil
import time
import httpx
import psutil
from fastapi import APIRouter
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api/system", tags=["system"])
_NVIDIA_SMI = shutil.which("nvidia-smi")
# Cache court : le front interroge /stats en continu ; relancer un sous-processus
# nvidia-smi à chaque tick charge la machine qui héberge aussi Ollama.
# TTL volontairement > à la cadence de sondage, sinon le cache n'absorbe rien.
_GPU_CACHE_TTL = 12.0
_gpu_cache: dict = {"at": 0.0, "value": None}
# Même logique pour la vue matériel (sous-processus nvidia-smi plus lourd).
_HW_CACHE_TTL = 12.0
_hw_cache: dict = {"at": 0.0, "value": None}
async def _gpu_stats() -> dict | None:
"""Utilisation GPU/VRAM via nvidia-smi ; None si absent (pas de GPU NVIDIA).
Résultat mis en cache ~5 s pour limiter les sous-processus.
"""
if not _NVIDIA_SMI:
return None
if time.monotonic() - _gpu_cache["at"] < _GPU_CACHE_TTL:
return _gpu_cache["value"]
try:
proc = await asyncio.create_subprocess_exec(
_NVIDIA_SMI,
"--query-gpu=utilization.gpu,memory.used,memory.total,name",
"--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
line = out.decode().strip().splitlines()[0]
util, used, total, name = (p.strip() for p in line.split(","))
value = {
"name": name,
"util_pct": float(util),
"vram_used_mb": float(used),
"vram_total_mb": float(total),
}
except Exception:
value = None
_gpu_cache.update(at=time.monotonic(), value=value)
return value
@router.get("/stats")
async def stats() -> dict:
"""CPU %, RAM et GPU/VRAM courants."""
cpu_pct = psutil.cpu_percent(interval=None)
mem = psutil.virtual_memory()
gpu = await _gpu_stats()
return {
"cpu_pct": cpu_pct,
"ram_used_go": round(mem.used / 1_000_000_000, 1),
"ram_total_go": round(mem.total / 1_000_000_000, 1),
"ram_pct": mem.percent,
"gpu": gpu,
}
# Battement groupé : absorbe les rafales (plusieurs onglets, reconnexions)
# sans multiplier les sous-processus ni les connexions vers Ollama.
_PULSE_TTL = 2.5
_pulse_cache: dict = {"at": 0.0, "value": None}
async def _loaded_models() -> list[dict]:
"""Modèles chargés en mémoire + placement GPU (vide si Ollama muet)."""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return []
out = []
for m in loaded:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
out.append({
"name": m.get("name") or m.get("model"),
"on_gpu": bool(size and vram >= size * 0.99),
"gpu_percent": int(vram / size * 100) if size else 0,
})
return out
@router.get("/pulse")
async def pulse() -> dict:
"""Battement unique : statut Ollama + ressources + modèles chargés.
Remplace trois sondages séparés (statut, stats, modèles chargés) par UNE
requête : moins de réveils, moins de sous-processus et moins de connexions
vers Ollama quand l'application est simplement ouverte sans être utilisée.
"""
if time.monotonic() - _pulse_cache["at"] < _PULSE_TTL and _pulse_cache["value"]:
return _pulse_cache["value"]
# Les deux appels Ollama partent ensemble : latence = le plus lent, pas la
# somme (important quand Ollama est sur une autre machine).
version, loaded = await asyncio.gather(
ollama.ping(), _loaded_models(), return_exceptions=True
)
connected = not isinstance(version, BaseException)
mem = psutil.virtual_memory()
value = {
"status": {
"connected": connected,
"host": ollama.host,
"version": version.get("version") if connected else None,
"default_model": settings.default_model,
**({} if connected else {"error": str(version)[:200]}),
},
"stats": {
"cpu_pct": psutil.cpu_percent(interval=None),
"ram_used_go": round(mem.used / 1_000_000_000, 1),
"ram_total_go": round(mem.total / 1_000_000_000, 1),
"ram_pct": mem.percent,
"gpu": await _gpu_stats(),
},
"loaded": [] if isinstance(loaded, BaseException) else loaded,
}
_pulse_cache.update(at=time.monotonic(), value=value)
return value
async def _all_local_gpus() -> list[dict]:
"""Tous les GPU NVIDIA visibles depuis le CONTENEUR Loki (peut être vide).
Mis en cache : le panneau Matériel se rafraîchit en continu tant qu'il est
ouvert, et chaque appel lançait un sous-processus nvidia-smi.
"""
if not _NVIDIA_SMI:
return []
if time.monotonic() - _hw_cache["at"] < _HW_CACHE_TTL:
return _hw_cache["value"] or []
try:
proc = await asyncio.create_subprocess_exec(
_NVIDIA_SMI,
"--query-gpu=index,name,memory.total,memory.used,utilization.gpu",
"--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
gpus = []
for line in out.decode().strip().splitlines():
idx, name, total, used, util = (p.strip() for p in line.split(","))
gpus.append({
"index": int(idx), "name": name,
"vram_total_mb": float(total), "vram_used_mb": float(used),
"util_pct": float(util),
})
_hw_cache.update(at=time.monotonic(), value=gpus)
return gpus
except Exception:
_hw_cache.update(at=time.monotonic(), value=[])
return []
@router.get("/hardware")
async def hardware() -> dict:
"""Vue matériel : GPU vu par Loki vs GPU réellement utilisé par Ollama."""
# 1) Ce que voit le conteneur Loki (nvidia-smi) + override éventuel.
local_gpus = await _all_local_gpus()
override = None
if settings.gpu_vram_mb > 0:
override = {
"name": settings.gpu_name or "GPU déclaré (GPU_VRAM_MB)",
"vram_total_mb": settings.gpu_vram_mb,
}
# 2) Ce qu'Ollama utilise réellement (modèles chargés + placement VRAM/CPU).
ollama_info: dict = {"host": ollama.host, "connected": False, "running": []}
try:
version = await ollama.ping()
ollama_info["connected"] = True
ollama_info["version"] = version.get("version")
except (httpx.HTTPError, OSError) as exc:
ollama_info["error"] = str(exc)[:200]
if ollama_info["connected"]:
try:
for m in await ollama.ps():
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0:
where = "inconnu"
elif vram >= size * 0.99:
where = "GPU"
elif vram <= size * 0.01:
where = "CPU"
else:
where = "mixte"
ollama_info["running"].append({
"name": m.get("name") or m.get("model"),
"processor": where,
"gpu_percent": int(vram / size * 100) if size else 0,
"size_mb": round(size / 1_000_000),
"vram_mb": round(vram / 1_000_000),
})
except (httpx.HTTPError, OSError):
pass
# Ollama est-il sur la même machine que Loki ? (heuristique sur l'hôte)
host = ollama.host.lower()
is_local = any(h in host for h in ("localhost", "127.0.0.1", "host.docker.internal"))
return {
"loki_gpus": local_gpus,
"gpu_override": override,
"ollama": ollama_info,
"ollama_is_local": is_local,
"note": (
"Loki ne voit pas directement le GPU d'un Ollama distant : il déduit "
"le placement (GPU/CPU) depuis les modèles chargés. Déclare GPU_VRAM_MB "
"pour l'auto-réglage si Ollama tourne sur une autre machine."
),
}
-117
View File
@@ -1,117 +0,0 @@
"""Skills : méthodes expertes injectées automatiquement selon la tâche.
Sélection lexicale instantanée (aucun appel LLM) : au plus UNE skill par
message, injectée en message système pour ce tour uniquement.
"""
from __future__ import annotations
import os
import re
_SKILLS_DIR = os.path.join(os.path.dirname(__file__), "..", "skills")
# Ponytail (https://github.com/DietrichGebert/ponytail) : philosophie de code
# « paresseux » (anti sur-ingénierie). Adaptée en méthode transverse injectée
# pour toute tâche de code — indépendante du sélecteur de skill (mono-skill).
PONYTAIL_TITLE = "Ponytail · code minimal"
PONYTAIL_GUIDANCE = (
"Méthode Ponytail — code minimal, anti sur-ingénierie. Avant d'écrire du "
"code, descends l'échelle de décision et arrête-toi au premier échelon qui "
"suffit :\n"
"1. NE PAS coder ce qui n'est pas explicitement demandé (YAGNI) ;\n"
"2. RÉUTILISER l'existant (fichiers/fonctions déjà présents) ;\n"
"3. UTILISER les fonctions natives du langage / du navigateur ;\n"
"4. en DERNIER recours seulement, écrire le minimum de code nécessaire.\n"
"Livre la solution la plus simple qui fonctionne : aucune dépendance ni "
"bibliothèque à installer, aucune abstraction prématurée, aucune "
"fonctionnalité en plus non demandée (pas de « moteur IA », d'options ou de "
"configuration superflues). Préfère un seul fichier clair à une "
"architecture élaborée."
)
# Seuil : nombre minimal de mots-clés distincts trouvés dans le message.
_MIN_HITS = 2
def _load() -> dict[str, dict]:
out: dict[str, dict] = {}
if not os.path.isdir(_SKILLS_DIR):
return out
for fname in sorted(os.listdir(_SKILLS_DIR)):
if not fname.endswith(".md"):
continue
with open(os.path.join(_SKILLS_DIR, fname), encoding="utf-8") as f:
raw = f.read()
m = re.match(r"^---\n(.*?)\n---\n(.*)$", raw, re.S)
if not m:
continue
meta: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" in line:
key, _, value = line.partition(":")
meta[key.strip()] = value.strip()
keywords = [
k.strip().lower()
for k in meta.get("keywords", "").split(",")
if k.strip()
]
out[meta.get("name", fname[:-3])] = {
"name": meta.get("name", fname[:-3]),
"title": meta.get("title", fname[:-3]),
"keywords": keywords,
"body": m.group(2).strip(),
}
return out
_WEB_RE = re.compile(
r"\b(html|css|javascript|js|page|site|web|appli|application|interface|"
r"bouton|formulaire|canvas|animation|jeu|game|échiquier|echiquier|"
r"dashboard|landing|maquette|ui|front)\b",
re.I,
)
def is_web_task(message: str) -> bool:
"""La demande porte-t-elle sur une page / appli web ?"""
return bool(_WEB_RE.search(message or ""))
# Contraintes « appli web » : le sandbox de prévisualisation est HORS-LIGNE et
# isolé (pas de CDN, pas de WASM téléchargé). Sans ces règles, les modèles
# promettent des libs (Stockfish, React…) qui ne se chargent jamais, éclatent
# le code en plusieurs fichiers incohérents, puis déclarent « ça marche ».
WEBAPP_GUIDANCE = (
"Contraintes STRICTES pour une page/appli web :\n"
"1. UN SEUL fichier index.html AUTONOME : tout le CSS dans <style>, tout le "
"JS dans <script> à la fin du <body>. Pas de fichiers .css/.js séparés.\n"
"2. AUCUNE ressource externe : pas de CDN, pas de <script src=\"https://…\">, "
"pas de bibliothèque à télécharger (jQuery, React, chess.js, Stockfish, "
".wasm…). Le sandbox est hors-ligne : ça ne se charge JAMAIS. Si une "
"fonctionnalité a besoin d'une lib, écris une version simple toi-même en "
"JavaScript natif.\n"
"3. Le rendu doit s'afficher AU CHARGEMENT : construis réellement le DOM "
"dans un script qui s'exécute (ex. génère les 64 cases de l'échiquier en "
"boucle). Pas de fonction jamais appelée, pas de conteneur laissé vide.\n"
"4. Ne prétends JAMAIS qu'une fonctionnalité marche sans l'avoir codée, et "
"ne « simule » pas un moteur en le faisant passer pour réel : dis "
"clairement ce qui est réel et ce qui ne l'est pas.\n"
"5. Design sobre, lisible et moderne — mais la priorité est que ça "
"FONCTIONNE vraiment."
)
ALL_SKILLS: dict[str, dict] = _load()
def pick_skill(message: str) -> dict | None:
"""Meilleure skill pour ce message, ou None si rien d'assez net."""
low = message.lower()
best, best_hits = None, 0
for skill in ALL_SKILLS.values():
hits = sum(1 for kw in skill["keywords"] if kw in low)
if hits > best_hits:
best, best_hits = skill, hits
if best is None or best_hits < _MIN_HITS:
return None
return {"name": best["name"], "title": best["title"], "body": best["body"]}
-944
View File
@@ -1,944 +0,0 @@
"""Outils de l'agent, exécutés côté serveur et confinés au workspace.
Chaque outil expose :
- une définition JSON (format function-calling Ollama/OpenAI) ;
- une implémentation Python qui renvoie un dict {ok, summary, ...}.
Toutes les opérations fichier sont strictement confinées à WORKSPACE_DIR :
toute tentative de sortie (../, chemin absolu hors workspace) est rejetée.
"""
from __future__ import annotations
import html
import json
import os
import re
import shutil
import subprocess
from contextvars import ContextVar
import httpx
from .config import settings
class ToolError(Exception):
"""Erreur d'exécution d'un outil (message destiné au modèle)."""
# Projet actif pour la requête en cours : re-racine tous les outils sur
# workspace/<projet>. None = racine du workspace (comportement historique).
_ACTIVE_PROJECT: ContextVar[str | None] = ContextVar("loki_project", default=None)
PROJECT_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$")
def set_project(name: str | None) -> None:
"""Fixe le projet actif de la requête (None = racine)."""
if name is not None and not PROJECT_NAME.match(name):
raise ToolError(f"nom de projet invalide : {name!r}")
_ACTIVE_PROJECT.set(name)
def active_root() -> str:
"""Racine effective (workspace ou projet), créée si nécessaire."""
return _workspace_root()
def _workspace_root() -> str:
root = os.path.abspath(settings.workspace_dir)
project = _ACTIVE_PROJECT.get()
if project:
root = os.path.join(root, project)
os.makedirs(root, exist_ok=True)
return root
def _safe_path(rel: str) -> str:
"""Résout un chemin relatif en restant confiné au workspace."""
root = _workspace_root()
target = os.path.abspath(os.path.join(root, rel or "."))
if target != root and not target.startswith(root + os.sep):
raise ToolError(f"chemin hors du workspace refusé : {rel}")
return target
# ── Implémentations ──────────────────────────────────────────────────────
# Fenêtrage de lecture : un gros fichier entier engloutit le contexte du
# modèle. Au-delà du seuil, on renvoie une fenêtre + la marche à suivre.
_READ_WINDOW_LINES = 200
_READ_MAX_CHARS = 12_000
def read_file(path: str, start_line: int = 1) -> dict:
target = _safe_path(path)
if os.path.isdir(target):
raise ToolError(
f"{path} est un dossier — utilise list_dir pour voir son contenu"
)
if not os.path.isfile(target):
raise ToolError(f"fichier introuvable : {path}")
with open(target, "r", encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
total = len(lines)
if total == 0:
return {"ok": True, "content": "", "summary": "fichier vide (0 octet)"}
start = max(1, int(start_line or 1))
window = lines[start - 1 : start - 1 + _READ_WINDOW_LINES]
content = "\n".join(window)
truncated_by_chars = False
if len(content) > _READ_MAX_CHARS:
content = content[:_READ_MAX_CHARS]
truncated_by_chars = True
end = start + len(window) - 1
if start == 1 and end >= total and not truncated_by_chars:
return {"ok": True, "content": content, "summary": f"{total} lignes lues"}
# Fenêtre partielle : le modèle sait où il en est et comment continuer.
note = (
f"[fichier {path} : {total} lignes — fenêtre {start}-{end}. "
f"Pour la suite : read_file(path, start_line={end + 1}). "
"Pour cibler un passage précis : grep_search puis edit_file.]"
)
return {
"ok": True,
"content": content + "\n" + note,
"summary": f"lignes {start}-{end} sur {total}",
}
def check_html(target: str) -> list[str]:
"""Contrôles rapides d'une page HTML : références locales et balises.
Renvoie une liste de problèmes (vide = OK).
"""
issues: list[str] = []
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError:
return issues
base_dir = os.path.dirname(target)
# Références href/src : cassées (fichier local absent) ou externes
# (CDN/WASM qui ne se chargeront pas dans un sandbox hors-ligne).
for attr, ref in re.findall(r"""(href|src)=["']([^"'#]+)["']""", content, re.I):
low = ref.lower()
if low.startswith(("data:", "mailto:", "tel:", "javascript:")):
continue
if re.match(r"^(https?:)?//", ref, re.I):
base = low.split("?")[0]
if attr.lower() == "src" and base.endswith((".js", ".mjs", ".wasm")):
issues.append(f"script externe (hors-ligne : ne se charge pas) : {ref}")
elif attr.lower() == "href" and base.endswith(".css"):
issues.append(f"style externe (hors-ligne : ne se charge pas) : {ref}")
continue
ref_path = os.path.normpath(os.path.join(base_dir, ref.split("?")[0]))
if not os.path.exists(ref_path):
issues.append(f"référence cassée : {ref}")
# Équilibre des balises structurantes.
for tag in ("html", "head", "body", "div", "section", "script", "style"):
opened = len(re.findall(rf"<{tag}[\s>]", content, re.I))
closed = len(re.findall(rf"</{tag}>", content, re.I))
if opened != closed:
issues.append(f"balise <{tag}> : {opened} ouverte(s) / {closed} fermée(s)")
return issues[:6]
def _verify_written(target: str) -> str | None:
"""Vérification immédiate après écriture (py/json/html).
Renvoyer l'erreur au modèle tout de suite lui permet de se corriger dans
le même tour, au lieu de livrer un fichier cassé.
"""
ext = os.path.splitext(target)[1].lower()
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
if ext == ".json":
import json as _json
_json.loads(content)
elif ext == ".py":
compile(content, target, "exec")
elif ext in (".html", ".htm"):
problems = check_html(target)
if problems:
return " ; ".join(problems)
elif ext in (".js", ".mjs"):
node = shutil.which("node")
if node:
proc = subprocess.run(
[node, "--check", target], capture_output=True, text=True,
timeout=15,
)
if proc.returncode != 0:
return (proc.stderr or proc.stdout)[:300]
except SyntaxError as exc:
return f"SyntaxError ligne {exc.lineno}: {exc.msg}"
except ValueError as exc:
return f"JSON invalide : {exc}"
except OSError:
return None
return None
def write_file(path: str, content: str, mode: str = "overwrite") -> dict:
target = _safe_path(path)
if mode not in {"overwrite", "append"}:
raise ToolError("mode write_file invalide : utilise overwrite ou append")
os.makedirs(os.path.dirname(target) or _workspace_root(), exist_ok=True)
existed = os.path.isfile(target)
with open(target, "a" if mode == "append" else "w", encoding="utf-8") as f:
f.write(content)
lines = len(content.splitlines())
verb = "complété" if mode == "append" else "modifié" if existed else "écrit"
result = {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
problem = _verify_written(target)
if problem:
result["verification"] = problem
result["summary"] += f" · ⚠ {problem}"
return result
def _leading_ws(line: str) -> str:
"""Renvoie l'indentation (blancs de gauche) d'une ligne."""
return line[: len(line) - len(line.lstrip())]
def _reindent(search_lines: list[str], window: list[str], replace: str) -> list[str]:
"""Réaligne le texte de remplacement sur l'indentation réelle du fichier.
Quand la correspondance a été trouvée en tolérant l'indentation (le modèle
a copié l'extrait « à plat »), on réapplique au remplacement le décalage
d'indentation observé entre le fichier et la recherche, pour ne pas casser
la mise en forme (Python surtout).
"""
src_indent = next((_leading_ws(s) for s in search_lines if s.strip()), "")
file_indent = next((_leading_ws(w) for w in window if w.strip()), "")
replace_lines = replace.splitlines()
if file_indent == src_indent:
return replace_lines
out: list[str] = []
for line in replace_lines:
if not line.strip():
out.append(line)
elif src_indent and line.startswith(src_indent):
out.append(file_indent + line[len(src_indent):])
elif not src_indent:
out.append(file_indent + line)
else:
out.append(line)
return out
def _apply_edit(content: str, search: str, replace: str) -> tuple[str, str]:
"""Applique un remplacement search→replace, du plus strict au plus tolérant.
1. correspondance exacte (unique) ;
2. correspondance ligne à ligne en ignorant les espaces de fin / de début
(indentation) — cas le plus fréquent où un petit modèle recopie l'extrait
sans reproduire fidèlement les blancs.
Renvoie (nouveau_contenu, note). Lève ToolError si introuvable ou ambigu.
"""
# 1. Correspondance exacte.
count = content.count(search)
if count == 1:
return content.replace(search, replace, 1), ""
if count > 1:
raise ToolError(
f"extrait présent {count} fois : ajoute du contexte "
"autour pour le rendre unique."
)
# 2. Correspondance tolérante (ligne à ligne, espaces normalisés).
file_lines = content.splitlines(keepends=True)
search_lines = search.splitlines()
if not any(s.strip() for s in search_lines):
raise ToolError("extrait vide après normalisation")
norm_search = [s.strip() for s in search_lines]
n = len(search_lines)
hits = [
i
for i in range(len(file_lines) - n + 1)
if [w.strip() for w in file_lines[i:i + n]] == norm_search
]
if not hits:
raise ToolError("introuvable")
if len(hits) > 1:
raise ToolError(
f"extrait présent {len(hits)} fois : ajoute du contexte "
"autour pour le rendre unique."
)
i = hits[0]
window = file_lines[i:i + n]
newline = "\r\n" if window and window[0].endswith("\r\n") else "\n"
adjusted = _reindent(search_lines, window, replace)
rep_text = newline.join(adjusted)
if window and window[-1].endswith("\n"):
rep_text += newline
new_content = "".join(file_lines[:i]) + rep_text + "".join(file_lines[i + n:])
return new_content, "correspondance tolérante (indentation/espaces ignorés)"
def edit_file(path: str, search: str, replace: str) -> dict:
"""Modification chirurgicale : remplace un extrait du fichier.
Bien plus fiable que réécrire tout le fichier avec un petit modèle : seul le
fragment visé change, le reste est garanti intact. La correspondance tolère
les différences d'espaces / d'indentation, pour ne pas bloquer quand le
modèle recopie l'extrait de façon approximative.
"""
target = _safe_path(path)
if not os.path.isfile(target):
raise ToolError(f"fichier introuvable : {path}")
if not search:
raise ToolError("search vide : fournis l'extrait à remplacer")
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
try:
new_content, note = _apply_edit(content, search, replace)
except ToolError as exc:
reason = str(exc)
if reason == "introuvable":
preview = search.strip().splitlines()[0][:60] if search.strip() else ""
raise ToolError(
f"extrait introuvable dans {path} (cherché : « {preview}… »). "
"Relis le fichier avec read_file et copie l'extrait EXACT, "
"ou utilise write_file pour réécrire le fichier."
) from exc
raise ToolError(f"{reason.rstrip('.')} dans {path}.") from exc
with open(target, "w", encoding="utf-8") as f:
f.write(new_content)
delta = len(replace.splitlines()) - len(search.splitlines())
summary = f"remplacé · {delta:+d} ligne(s)"
if note:
summary += f" · {note}"
result = {"ok": True, "summary": summary}
problem = _verify_written(target)
if problem:
result["verification"] = problem
result["summary"] += f" · ⚠ {problem}"
return result
def memory_search(query: str) -> dict:
"""Cherche dans les notes que l'agent a lui-même enregistrées."""
from . import memory_notes
hits = memory_notes.search_notes(query, limit=3)
if not hits:
return {"ok": True, "notes": [], "summary": "aucune note correspondante"}
return {
"ok": True,
"notes": hits,
"summary": f"{len(hits)} note(s) trouvée(s)",
}
def memory_save(title: str, content: str) -> dict:
"""Enregistre une note durable (préférence, décision, fait à retenir)."""
from . import memory_notes
result = memory_notes.save_note(title, content)
if not result["ok"]:
raise ToolError(result["summary"])
return result
_GREP_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "dist"}
_MAX_GREP_MATCHES = 50
def grep_search(pattern: str, path: str = ".") -> dict:
"""Recherche un motif (regex) dans les fichiers du workspace."""
if not pattern:
raise ToolError("pattern vide")
try:
rx = re.compile(pattern, re.I)
except re.error as exc:
raise ToolError(f"regex invalide : {exc}")
root = _safe_path(path)
matches: list[str] = []
files_hit: set[str] = set()
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in _GREP_SKIP_DIRS]
for fname in sorted(filenames):
full = os.path.join(dirpath, fname)
if os.path.getsize(full) > 1_000_000:
continue
rel = os.path.relpath(full, _workspace_root())
try:
with open(full, "r", encoding="utf-8", errors="replace") as f:
for lineno, line in enumerate(f, 1):
if rx.search(line):
matches.append(f"{rel}:{lineno}: {line.strip()[:160]}")
files_hit.add(rel)
if len(matches) >= _MAX_GREP_MATCHES:
break
except OSError:
continue
if len(matches) >= _MAX_GREP_MATCHES:
break
if len(matches) >= _MAX_GREP_MATCHES:
break
summary = (
f"{len(matches)} correspondance(s) dans {len(files_hit)} fichier(s)"
if matches else "aucune correspondance"
)
return {"ok": True, "matches": matches, "summary": summary}
def list_dir(path: str = ".") -> dict:
target = _safe_path(path)
if not os.path.isdir(target):
raise ToolError(f"répertoire introuvable : {path}")
entries = []
for name in sorted(os.listdir(target)):
full = os.path.join(target, name)
entries.append({"name": name, "type": "dir" if os.path.isdir(full) else "file"})
return {
"ok": True,
"entries": entries,
"summary": f"{len(entries)} élément(s)",
}
def web_search(query: str, max_results: int = 5) -> dict:
"""Recherche web (DuckDuckGo HTML, sans clé d'API).
Optionnellement, si SEARX_URL est défini, interroge une instance SearxNG.
Renvoie une liste de résultats {title, url, snippet}.
"""
query = (query or "").strip()
if not query:
raise ToolError("requête de recherche vide")
searx = os.environ.get("SEARX_URL")
try:
if searx:
results = _search_searx(searx, query, max_results)
else:
results = _search_duckduckgo(query, max_results)
except httpx.HTTPError as exc:
raise ToolError(f"recherche web indisponible : {exc}") from exc
summary = f"{len(results)} résultat(s)" if results else "aucun résultat"
return {"ok": True, "results": results, "summary": summary}
def _search_searx(base: str, query: str, n: int) -> list[dict]:
with httpx.Client(timeout=10.0) as client:
r = client.get(
base.rstrip("/") + "/search",
params={"q": query, "format": "json"},
)
r.raise_for_status()
data = r.json().get("results", [])[:n]
return [
{"title": d.get("title", ""), "url": d.get("url", ""),
"snippet": d.get("content", "")}
for d in data
]
_DDG_RESULT = re.compile(
r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>'
r'.*?class="result__snippet"[^>]*>(.*?)</a>',
re.DOTALL,
)
_TAGS = re.compile(r"<[^>]+>")
def _clean(text: str) -> str:
return html.unescape(_TAGS.sub("", text)).strip()
def _search_duckduckgo(query: str, n: int) -> list[dict]:
with httpx.Client(timeout=10.0, follow_redirects=True) as client:
r = client.post(
"https://html.duckduckgo.com/html/",
data={"q": query},
headers={"User-Agent": "Mozilla/5.0 (Loki agent)"},
)
r.raise_for_status()
results = []
for url, title, snippet in _DDG_RESULT.findall(r.text)[:n]:
results.append({
"title": _clean(title),
"url": html.unescape(url),
"snippet": _clean(snippet),
})
return results
# Lignes porteuses de signal dans une sortie de commande en échec.
_ERROR_LINE = re.compile(
r"error|erreur|fail|except|traceback|fatal|warn|undefined|cannot|"
r"not found|introuvable|refus|denied|invalid|missing|panic",
re.I,
)
def _dedupe_lines(lines: list[str]) -> list[str]:
"""Compacte les répétitions consécutives (« ligne ×N »)."""
out: list[str] = []
for line in lines:
if out:
base, _, count = out[-1].partition(" ×")
if base == line:
n = int(count) if count.isdigit() else 1
out[-1] = f"{line} ×{n + 1}"
continue
out.append(line)
return out
def _compact_output(output: str, exit_code: int) -> str:
"""Filtre la sortie shell façon rtk : le signal, pas le déroulé.
- succès : dernières lignes seulement (le détail n'apporte rien) ;
- échec : lignes d'erreur + fin de sortie, dédupliquées.
Tronquer bêtement à N caractères gardait le bruit et coupait l'erreur.
"""
lines = [l.rstrip() for l in output.splitlines() if l.strip()]
lines = _dedupe_lines(lines)
if exit_code == 0:
kept = lines[-12:]
text = "\n".join(kept)
if len(lines) > 12:
text = f"[…{len(lines) - 12} lignes omises]\n" + text
return text[:1200]
error_lines = [l for l in lines if _ERROR_LINE.search(l)]
tail = lines[-10:]
kept = error_lines[:20] + [l for l in tail if l not in error_lines[:20]]
text = "\n".join(kept)
if len(lines) > len(kept):
text = f"[…sortie filtrée : {len(kept)}/{len(lines)} lignes]\n" + text
return text[:2500]
# Pseudo-fichiers absolus inoffensifs, tolérés dans les commandes shell.
_ALLOWED_ABS = ("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/zero", "/dev/tty")
# Jetons ressemblant à un chemin (absolu, ~ ou contenant ../).
_PATH_TOKEN = re.compile(r"""(?:^|[\s=:><|&(])((?:~|/)[^\s'"|&;><)]*|[^\s'"|&;><)]*\.\.[^\s'"|&;><)]*)""")
def _guard_shell(command: str) -> None:
"""Refuse toute commande qui référence un chemin hors du workspace.
Barrière de confinement (best-effort) : le shell est trop puissant pour être
totalement bridé, mais on bloque les cas concrets d'évasion — chemins
absolus hors workspace (`/config/...`, `~/...`) et remontées `../` qui
sortent du workspace. Combiné à la validation utilisateur, ça empêche le
modèle d'écrire ailleurs que dans son workspace.
"""
# Racine autorisée = base du workspace (pas le sous-dossier projet) : ainsi
# `ls /workspace` reste permis même quand la session cible un projet.
root = os.path.abspath(settings.workspace_dir)
for token in _PATH_TOKEN.findall(command):
token = token.strip()
if not token:
continue
# Jeton commençant par `//` = autorité d'URL (http://, ws://…) ou chemin
# réseau, jamais une évasion du système de fichiers : on l'ignore.
if token.startswith("//"):
continue
if token.startswith("~"):
raise ToolError(
f"chemin hors du workspace refusé : {token}. "
"Utilise uniquement des chemins relatifs au workspace."
)
if token.startswith("/"):
if any(token == a or token.startswith(a + "/") for a in _ALLOWED_ABS):
continue
resolved = os.path.abspath(token)
if resolved != root and not resolved.startswith(root + os.sep):
raise ToolError(
f"chemin absolu hors du workspace refusé : {token}. "
"Utilise uniquement des chemins relatifs au workspace."
)
elif ".." in token.split("/"):
resolved = os.path.abspath(os.path.join(root, token))
if resolved != root and not resolved.startswith(root + os.sep):
raise ToolError(
f"remontée hors du workspace refusée : {token}. "
"Reste dans le workspace."
)
def run_shell(command: str, timeout: int = 60) -> dict:
"""Exécute une commande shell dans le workspace (outil sensible).
L'exécution effective n'a lieu qu'après validation utilisateur (gérée par
la boucle agentique / la route /api/shell). Confinée au workspace.
"""
command = (command or "").strip()
if not command:
raise ToolError("commande vide")
_guard_shell(command)
try:
proc = subprocess.run(
command,
shell=True,
cwd=_workspace_root(),
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
raise ToolError(f"délai dépassé ({timeout}s)") from exc
out = (proc.stdout or "") + (proc.stderr or "")
# Filtrage signal/bruit (façon rtk) plutôt que troncature aveugle.
out = _compact_output(out, proc.returncode)
status = "ok" if proc.returncode == 0 else "error"
return {
"ok": proc.returncode == 0,
"exit_code": proc.returncode,
"output": out,
"summary": f"code {proc.returncode}",
"_status": status,
}
def run_check(path: str) -> dict:
"""Vérification STATIQUE d'un fichier de code — n'exécute jamais rien.
.py -> py_compile ; .js/.mjs -> node --check (si node présent) ;
.html -> check_html ; .json -> parse. Autres types : ok sans contrôle.
"""
target = _safe_path(path)
if not os.path.isfile(target):
raise ToolError(f"fichier introuvable : {path}")
ext = os.path.splitext(target)[1].lower()
issues: list[str] = []
if ext == ".py":
import py_compile
try:
py_compile.compile(target, doraise=True)
except py_compile.PyCompileError as exc:
issues.append(str(exc.msg)[:500])
elif ext in (".js", ".mjs"):
node = shutil.which("node")
if node:
proc = subprocess.run(
[node, "--check", target], capture_output=True, text=True,
timeout=15,
)
if proc.returncode != 0:
issues.append((proc.stderr or proc.stdout)[:500])
elif ext in (".html", ".htm"):
issues.extend(check_html(target))
elif ext == ".json":
try:
with open(target, encoding="utf-8") as f:
json.load(f)
except json.JSONDecodeError as exc:
issues.append(f"JSON invalide : {exc}")
ok = not issues
return {
"ok": ok,
"issues": issues,
"summary": "aucun problème" if ok else f"{len(issues)} problème(s)",
"_status": "ok" if ok else "error",
}
# ── Registre & définitions exposées au modèle ────────────────────────────
TOOL_IMPL = {
"read_file": read_file,
"write_file": write_file,
"edit_file": edit_file,
"list_dir": list_dir,
"grep_search": grep_search,
"web_search": web_search,
"run_shell": run_shell,
"run_check": run_check,
"memory_search": memory_search,
"memory_save": memory_save,
}
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": (
"Lire le contenu d'un fichier du workspace. Les gros fichiers "
"sont renvoyés par fenêtres de 200 lignes : utilise start_line "
"pour lire la suite, ou grep_search pour cibler un passage."
),
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"},
"start_line": {
"type": "integer",
"description": "Première ligne de la fenêtre (défaut 1)",
},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Créer ou modifier un fichier du workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"},
"content": {
"type": "string",
"description": "Contenu complet ou morceau court du fichier",
},
"mode": {
"type": "string",
"enum": ["overwrite", "append"],
"description": (
"overwrite pour le premier morceau, append pour les suivants"
),
},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": (
"Modifier UN extrait précis d'un fichier existant (recherche/"
"remplacement exact). Préférable à write_file pour toute "
"modification partielle : le reste du fichier reste intact."
),
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"},
"search": {
"type": "string",
"description": "Extrait EXACT à remplacer (copie fidèle, unique dans le fichier)",
},
"replace": {"type": "string", "description": "Nouveau texte"},
},
"required": ["path", "search", "replace"],
},
},
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "Lister le contenu d'un répertoire du workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Répertoire (défaut : racine)"}
},
},
},
},
{
"type": "function",
"function": {
"name": "grep_search",
"description": (
"Chercher un motif (regex, insensible à la casse) dans tous les "
"fichiers du workspace. Renvoie fichier:ligne:texte."
),
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Motif à chercher"},
"path": {"type": "string", "description": "Sous-répertoire (défaut : racine)"},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "code_task",
"description": (
"Confier une tâche de programmation au moteur code (édition "
"multi-fichiers fiable, commits git). À utiliser pour créer ou "
"modifier du code."
),
"parameters": {
"type": "object",
"properties": {
"instruction": {
"type": "string",
"description": "La tâche de code, précise et complète",
},
"files": {
"type": "array",
"items": {"type": "string"},
"description": "Fichiers concernés (optionnel)",
},
},
"required": ["instruction"],
},
},
},
{
"type": "function",
"function": {
"name": "web_search",
"description": "Rechercher sur le web et renvoyer les meilleurs résultats.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Termes de recherche"}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "run_shell",
"description": (
"Exécuter une commande shell dans le workspace. Outil sensible :"
" l'utilisateur doit valider la commande avant exécution."
),
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Commande à exécuter"}
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "run_check",
"description": (
"Vérifier statiquement un fichier de code du workspace "
"(syntaxe Python/JS/JSON, structure HTML). N'exécute rien."
),
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "memory_search",
"description": (
"Chercher dans tes notes durables (préférences de l'utilisateur, "
"décisions, faits appris lors d'anciennes discussions)."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ce que tu cherches"}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "memory_save",
"description": (
"Enregistrer une note durable, réutilisable dans les prochaines "
"discussions. À réserver aux informations stables et utiles "
"(préférence, choix technique, contrainte). Jamais le détail "
"d'une tâche en cours."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Titre court et parlant"},
"content": {"type": "string", "description": "La note, concise"},
},
"required": ["title", "content"],
},
},
},
]
def _normalize_args(name: str, args: dict | None) -> dict:
args = dict(args or {})
if name == "write_file":
if "path" not in args:
for alias in ("file_path", "filepath", "filename", "file", "name"):
if args.get(alias):
args["path"] = args[alias]
break
if "content" not in args:
for alias in ("text", "body", "data", "contents"):
if alias in args:
args["content"] = args[alias]
break
elif name in {"read_file", "list_dir", "edit_file"} and "path" not in args:
for alias in ("file_path", "filepath", "filename", "file", "dir", "directory"):
if args.get(alias):
args["path"] = args[alias]
break
if name == "edit_file":
if "search" not in args and "old" in args:
args["search"] = args.pop("old")
if "replace" not in args and "new" in args:
args["replace"] = args.pop("new")
elif name == "grep_search" and "pattern" not in args:
for alias in ("query", "search", "text", "regex"):
if args.get(alias):
args["pattern"] = args[alias]
break
return args
def run_tool(name: str, args: dict) -> dict:
"""Exécute un outil par son nom ; lève ToolError si inconnu/invalide."""
impl = TOOL_IMPL.get(name)
if impl is None:
raise ToolError(f"outil inconnu : {name}")
args = _normalize_args(name, args)
if name == "write_file":
missing = [key for key in ("path", "content") if key not in args]
if missing:
raise ToolError(
"arguments invalides pour write_file : "
f"{', '.join(missing)} requis. Utilise par exemple "
'{"path":"index.html","content":"...","mode":"overwrite"}.'
)
try:
return impl(**args)
except ToolError:
raise
except TypeError as exc:
raise ToolError(f"arguments invalides pour {name} : {exc}") from exc
except OSError as exc:
raise ToolError(f"erreur système ({name}) : {exc}") from exc
-2
View File
@@ -1,2 +0,0 @@
[pytest]
asyncio_mode = auto
-13
View File
@@ -1,13 +0,0 @@
# Aider fige ses dépendances partagées (fastapi, pydantic, httpx…) : il est
# listé en premier et le reste est aligné sur ses versions pour éviter tout
# conflit de résolution pip.
aider-chat==0.86.2
fastapi==0.128.8
uvicorn[standard]==0.34.0
httpx==0.28.1
pydantic==2.12.5
pydantic-settings==2.7.1
psutil==7.2.2
mcp>=1.9
pytest>=8.3
pytest-asyncio>=0.25
-11
View File
@@ -1,11 +0,0 @@
---
name: analyse-donnees
title: Analyse de données
keywords: csv, json, données, tableau, statistique, moyenne, analyse, colonnes, tri, filtre, graphique, export
---
Méthode d'analyse de données :
1. EXAMINER : lis un échantillon du fichier réel (read_file) AVANT tout traitement. Identifie séparateur, encodage, en-têtes, types de colonnes.
2. VALIDER : repère valeurs manquantes, doublons, incohérences de type. Signale-les explicitement au lieu de les masquer.
3. TRANSFORMER : script clair et borné (pas de dépendance exotique) ; garde les données d'origine intactes, écris le résultat dans un nouveau fichier.
4. PRÉSENTER : résumé chiffré (compte, min/max, moyennes pertinentes) + limites de l'analyse (données ignorées, hypothèses faites).
Interdit : supposer le format sans avoir lu le fichier ; modifier les données sources ; présenter des chiffres sans dire comment ils sont calculés.
-11
View File
@@ -1,11 +0,0 @@
---
name: creation-web
title: Création web
keywords: site, page, html, css, landing, formulaire, portfolio, interface, responsive, boutique, vitrine, menu, header, footer, animation
---
Méthode de création web, dans cet ordre :
1. STRUCTURE : écris d'abord le HTML sémantique COMPLET (header, main, sections, footer). Contenu réel, pas de lorem ipsum si le sujet est connu.
2. STYLE : CSS cohérent — palette limitée (3-4 couleurs), typographie lisible, espacements réguliers. Mobile-first, responsive (flexbox/grid, max-width sur images).
3. INTERACTIVITÉ : JavaScript minimal et sans dépendance externe. Chaque interaction doit fonctionner hors ligne.
4. VÉRIFIER : run_check sur chaque fichier produit ; contrôle les liens internes. Si les outils navigateur (mcp_playwright_*) sont disponibles, ouvre la page et lis la console pour vérifier qu'elle est propre.
Interdit : livrer sans vérification ; référencer des images ou CDN externes non demandés ; produire un fichier tronqué (utilise write_file en plusieurs morceaux).
-12
View File
@@ -1,12 +0,0 @@
---
name: debogage-systematique
title: Débogage systématique
keywords: bug, plante, erreur, exception, traceback, crash, échoue, marche pas, fonctionne pas, cassé, debug, débogue, corrige le bug, ne s'affiche pas, undefined, null, NaN
---
Méthode de débogage à suivre STRICTEMENT, étape par étape :
1. REPRODUIRE : identifie l'entrée exacte et le comportement observé vs attendu. Si le message d'erreur est fourni, cite-le et pars de là.
2. LOCALISER : lis le code concerné (read_file / grep_search) AVANT toute modification. Trouve la ligne qui produit le symptôme.
3. HYPOTHÈSE : formule UNE cause précise. Vérifie-la en lisant le code, pas en devinant.
4. CORRIGER : modification minimale et ciblée (edit_file). Ne réécris pas tout le fichier. Ne corrige qu'une cause à la fois.
5. VÉRIFIER : relis le code modifié (run_check) et explique pourquoi le symptôme disparaît. Signale tout autre problème repéré sans le corriger.
Interdit : proposer une correction sans avoir lu le code ; corriger plusieurs choses à la fois ; conclure « ça devrait marcher » sans vérification.
-10
View File
@@ -1,10 +0,0 @@
---
name: redaction-structuree
title: Rédaction structurée
keywords: rédige, écris un texte, article, documentation, readme, rapport, résumé, lettre, mail, présentation, plan
---
Méthode de rédaction :
1. PLAN : annonce la structure (titres/sections) avant de rédiger. Adapte la longueur à la demande — court par défaut.
2. RÉDACTION : paragraphes courts, une idée par paragraphe, français précis. Concret d'abord, généralités interdites. Markdown propre (titres, listes, gras parcimonieux).
3. RELECTURE : traque répétitions, phrases creuses, incohérences de ton. Coupe tout ce qui n'apporte rien.
Interdit : remplissage, formules toutes faites (« dans un monde où… »), conclusion qui répète l'introduction.
-11
View File
@@ -1,11 +0,0 @@
---
name: refactor-sur
title: Refactor sûr
keywords: refactor, refactorise, réorganise, nettoie, simplifie, renomme, découpe, extrait, duplication, dette
---
Méthode de refactoring, comportement STRICTEMENT identique avant/après :
1. COMPRENDRE : lis TOUT le code concerné et ses usages (grep_search sur chaque symbole touché) avant de modifier quoi que ce soit.
2. PETITS PAS : une seule transformation à la fois (renommage, extraction, déplacement). Jamais plusieurs changements mélangés.
3. VÉRIFIER après CHAQUE pas : run_check sur les fichiers modifiés ; re-grep pour confirmer qu'aucun usage n'est resté sur l'ancien nom.
4. RÉCAPITULER : liste ce qui a changé et pourquoi le comportement est inchangé.
Interdit : changer le comportement ou l'API publique sans le signaler ; renommer sans vérifier tous les usages ; réécrire un fichier entier quand edit_file suffit.
View File
Whitespace-only changes.
-124
View File
@@ -1,124 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import agent # noqa: E402
def _convo():
return [
{"role": "system", "content": "consigne"},
{"role": "user", "content": "fais la tâche"},
]
@pytest.mark.asyncio
async def test_relance_apres_reflexion_seule(monkeypatch):
"""1er appel : réflexion seule -> relance ; 2e appel : réponse finale."""
calls: list[list[dict]] = []
async def fake_chat(model, convo, **kwargs):
calls.append([dict(m) for m in convo])
if len(calls) == 1:
yield {"message": {"thinking": "hmm, je réfléchis longuement…"},
"done": False}
yield {"message": {}, "done": True}
else:
yield {"message": {"content": "réponse finale"}, "done": True}
monkeypatch.setattr(agent.ollama, "chat", fake_chat)
events = [
e async for e in agent.run_agent("test", _convo(), enabled_tools=[])
]
final = [e for e in events if e["type"] == "final"]
assert final and "réponse finale" in final[0]["content"]
# La relance a bien injecté la consigne de reprise.
assert any(
"Continue la tâche" in m["content"]
for m in calls[1] if m["role"] == "user"
)
@pytest.mark.asyncio
async def test_reflexion_pas_renvoyee_au_modele(monkeypatch):
"""La pensée est affichée mais jamais réinjectée dans l'historique."""
calls: list[list[dict]] = []
async def fake_chat(model, convo, **kwargs):
calls.append([dict(m) for m in convo])
if len(calls) == 1:
yield {"message": {"thinking": "je planifie",
"content": "étape 1",
"tool_calls": [{"function": {
"name": "list_dir",
"arguments": {"path": "."}}}]},
"done": True}
else:
yield {"message": {"content": "terminé"}, "done": True}
monkeypatch.setattr(agent.ollama, "chat", fake_chat)
events = [
e async for e in agent.run_agent("test", _convo(), enabled_tools=None)
]
final = [e for e in events if e["type"] == "final"]
assert final and "terminé" in final[0]["content"]
assert "je planifie" in final[0]["thinking"] # gardée pour l'UI
# Aucun message assistant réinjecté ne contient la clé thinking.
assistant_turns = [m for m in calls[1] if m["role"] == "assistant"]
assert assistant_turns
assert all("thinking" not in m for m in assistant_turns)
@pytest.mark.asyncio
async def test_coupe_circuit_pensee_interminable(monkeypatch):
"""Pensée sans fin -> génération coupée en vol, puis relance qui aboutit."""
calls: list[int] = []
async def fake_chat(model, convo, **kwargs):
calls.append(1)
if len(calls) == 1:
# Flux de pensée « infini » : jamais de done, jamais de contenu.
for _ in range(10_000):
yield {"message": {"thinking": "x" * 200}, "done": False}
else:
yield {"message": {"content": "réponse après coupe"}, "done": True}
monkeypatch.setattr(agent.ollama, "chat", fake_chat)
events = [
e async for e in agent.run_agent("test", _convo(), enabled_tools=[])
]
final = [e for e in events if e["type"] == "final"]
assert final and "réponse après coupe" in final[0]["content"]
assert len(calls) == 2 # coupé puis relancé, pas d'épuisement du flux
@pytest.mark.asyncio
async def test_reflexion_coupee_apres_deux_impasses(monkeypatch):
"""Deux itérations de pensée pure -> think désactivé, tâche finie."""
seen_think: list = []
async def fake_chat(model, convo, think=None, **kwargs):
seen_think.append(think)
if len(seen_think) <= 2:
yield {"message": {"thinking": "boucle de pensée"}, "done": True}
else:
yield {"message": {"content": "fini sans réfléchir"}, "done": True}
monkeypatch.setattr(agent.ollama, "chat", fake_chat)
events = [
e async for e in agent.run_agent(
"test", _convo(), enabled_tools=[], think=True
)
]
final = [e for e in events if e["type"] == "final"]
assert final and "fini sans réfléchir" in final[0]["content"]
assert seen_think[-1] is False # think coupé pour l'appel final
assert any(e["type"] == "notice" for e in events)
-62
View File
@@ -1,62 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import agent, db, mcp_client # noqa: E402
db.init_db()
def test_prune_compacte_les_anciens_resultats():
big = "x" * 5000
convo = [
{"role": "system", "content": "consigne"},
{"role": "user", "content": "tâche"},
{"role": "assistant", "content": "", "tool_calls": [{}]},
{"role": "tool", "tool_name": "read_file", "content": big},
{"role": "assistant", "content": "", "tool_calls": [{}]},
{"role": "tool", "tool_name": "grep_search", "content": big},
]
# Le dernier lot (index 5) commence à 5 : seul l'index 3 est compacté.
agent._prune_old_tool_results(convo, before_index=5)
assert len(convo[3]["content"]) < 500
assert "archivé" in convo[3]["content"]
assert convo[5]["content"] == big # lot courant intact
assert convo[0]["content"] == "consigne" # système intact
def test_prune_ignore_les_petits_resultats():
convo = [{"role": "tool", "tool_name": "list_dir", "content": "court"}]
agent._prune_old_tool_results(convo, before_index=1)
assert convo[0]["content"] == "court"
@pytest.mark.asyncio
async def test_searxng_sans_url_erreur_claire():
mgr = mcp_client.McpManager()
try:
result = await mgr.test_server("searxng")
assert result["ok"] is False
assert "SEARXNG_URL" in (result["error"] or "")
assert "requis" in (result["error"] or "")
finally:
await mgr.aclose()
def test_migration_v6_monte_les_anciens_defauts():
from app import agent_config
profiles = db.get_config_value(agent_config.MODEL_PROFILES_KEY) or {}
profiles["testmodel:7b"] = {"num_ctx": 8192}
profiles["custom:7b"] = {"num_ctx": 5000}
db.set_config_value(agent_config.MODEL_PROFILES_KEY, profiles)
db.set_config_value(agent_config.PROFILE_STATE_KEY, {"version": 5})
agent_config._migrate_profiles()
migrated = db.get_config_value(agent_config.MODEL_PROFILES_KEY)
assert migrated["testmodel:7b"]["num_ctx"] == 16384 # ancien défaut monté
assert migrated["custom:7b"]["num_ctx"] == 5000 # valeur perso respectée
-39
View File
@@ -1,39 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import db # noqa: E402
from app import mcp_client # noqa: E402
db.init_db()
def test_catalog_has_preconfigured_servers():
for sid in ("playwright", "context7", "fetch", "searxng", "custom"):
assert sid in mcp_client.CATALOG
assert mcp_client.CATALOG[sid]["label"]
def test_state_defaults_disabled():
state = mcp_client.get_mcp_state()
assert set(state) == set(mcp_client.CATALOG)
assert all(not s["enabled"] for s in state.values())
def test_toggle_persists():
mcp_client.set_mcp_state("fetch", enabled=True, params={})
assert mcp_client.get_mcp_state()["fetch"]["enabled"] is True
mcp_client.set_mcp_state("fetch", enabled=False, params={})
assert mcp_client.get_mcp_state()["fetch"]["enabled"] is False
def test_custom_params_persist():
mcp_client.set_mcp_state(
"custom", enabled=False, params={"command": "npx -y some-mcp"}
)
assert (
mcp_client.get_mcp_state()["custom"]["params"]["command"]
== "npx -y some-mcp"
)
-96
View File
@@ -1,96 +0,0 @@
import os
import sys
import tempfile
import textwrap
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import db, mcp_client # noqa: E402
db.init_db()
# Serveur MCP minimal : un outil "echo" + un outil au nom À TIRETS
# (comme Context7 « resolve-library-id »).
_FAKE_SERVER = textwrap.dedent("""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("fake")
@mcp.tool()
def echo(text: str) -> str:
\"\"\"Répète le texte fourni.\"\"\"
return "echo:" + text
@mcp.tool(name="dash-tool-name")
def dash_tool(text: str) -> str:
\"\"\"Outil au nom à tirets.\"\"\"
return "dash:" + text
mcp.run()
""")
@pytest.fixture()
def fake_server_cmd(tmp_path):
path = tmp_path / "fake_mcp.py"
path.write_text(_FAKE_SERVER, encoding="utf-8")
return [sys.executable, str(path)]
@pytest.mark.asyncio
async def test_tools_exposed_and_called(fake_server_cmd, monkeypatch):
monkeypatch.setitem(
mcp_client.CATALOG, "fake",
{"label": "Fake", "description": "", "command": fake_server_cmd,
"url_param": False, "env_params": [], "expose": None},
)
mcp_client.set_mcp_state("fake", enabled=True, params={})
mgr = mcp_client.McpManager()
try:
defs = await mgr.tool_definitions()
names = [d["function"]["name"] for d in defs]
assert "mcp_fake_echo" in names
# Nom à tirets exposé assaini (compatibilité function-calling).
assert "mcp_fake_dash_tool_name" in names
result = await mgr.call_tool("mcp_fake_echo", {"text": "bonjour"})
assert result["ok"] is True
assert "echo:bonjour" in result["content"]
# Appel via le nom assaini -> résolu vers le vrai nom à tirets.
dash = await mgr.call_tool("mcp_fake_dash_tool_name", {"text": "x"})
assert dash["ok"] is True and "dash:x" in dash["content"]
# Tolérance : le modèle répond avec des tirets au lieu d'underscores.
mixed = await mgr.call_tool("mcp_fake_dash-tool-name", {"text": "y"})
assert mixed["ok"] is True and "dash:y" in mixed["content"]
finally:
await mgr.aclose()
mcp_client.set_mcp_state("fake", enabled=False, params={})
@pytest.mark.asyncio
async def test_disabled_server_exposes_nothing():
mgr = mcp_client.McpManager()
try:
assert await mgr.tool_definitions() == []
finally:
await mgr.aclose()
@pytest.mark.asyncio
async def test_broken_server_never_raises(monkeypatch):
monkeypatch.setitem(
mcp_client.CATALOG, "broken",
{"label": "Broken", "description": "",
"command": [sys.executable, "-c", "import sys; sys.exit(3)"],
"url_param": False, "env_params": [], "expose": None},
)
mcp_client.set_mcp_state("broken", enabled=True, params={})
mgr = mcp_client.McpManager()
try:
assert await mgr.tool_definitions() == []
assert mgr.statuses()["broken"]["state"] == "error"
assert any("broken" in n.lower() or "Broken" in n for n in mgr.notices())
finally:
await mgr.aclose()
mcp_client.set_mcp_state("broken", enabled=False, params={})
-62
View File
@@ -1,62 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import db, tools # noqa: E402
from app.config import settings # noqa: E402
db.init_db()
_ROOT = os.path.abspath(settings.workspace_dir)
@pytest.fixture(autouse=True)
def _reset_project():
tools.set_project(None)
yield
tools.set_project(None)
def test_racine_par_defaut():
assert tools.active_root() == _ROOT
def test_set_project_reracine():
tools.set_project("demo")
root = tools.active_root()
assert root == os.path.join(_ROOT, "demo")
assert os.path.isdir(root) # créée à la volée
def test_confinement_conserve():
tools.set_project("demo")
with pytest.raises(tools.ToolError):
tools._safe_path("../hors-projet")
def test_nom_projet_invalide():
for bad in ("../x", "UPPER", "a b", "", "x" * 50):
with pytest.raises(tools.ToolError):
tools.set_project(bad)
def test_session_porte_son_projet():
s = db.create_session("t", None, project="demo")
assert db.get_session(s["id"])["project"] == "demo"
db.set_session_project(s["id"], None)
assert db.get_session(s["id"])["project"] is None
def test_aides_contexte_suivent_le_projet():
from app.routes.chat import _mentioned_files, _workspace_listing
tools.set_project("ctxdemo")
root = tools.active_root()
with open(os.path.join(root, "app.py"), "w", encoding="utf-8") as f:
f.write("x = 1")
assert "app.py" in _workspace_listing()
assert _mentioned_files("corrige app.py") == ["app.py"]
tools.set_project(None)
assert _mentioned_files("corrige app.py") == [] # absent de la racine
-46
View File
@@ -1,46 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.config import settings # noqa: E402
client = TestClient(app)
_ROOT = os.path.abspath(settings.workspace_dir)
def test_creation_et_liste():
r = client.post("/api/projects", json={"name": "routedemo"})
assert r.status_code == 201
assert os.path.isdir(os.path.join(_ROOT, "routedemo", ".git"))
names = [p["name"] for p in client.get("/api/projects").json()["projects"]]
assert "routedemo" in names
def test_nom_invalide_400():
assert client.post("/api/projects", json={"name": "../x"}).status_code == 400
assert client.post("/api/projects", json={"name": "Demo"}).status_code == 400
def test_existant_400():
client.post("/api/projects", json={"name": "dup"})
assert client.post("/api/projects", json={"name": "dup"}).status_code == 400
def test_files_re_racine():
client.post("/api/projects", json={"name": "scoped"})
with open(os.path.join(_ROOT, "scoped", "a.txt"), "w") as f:
f.write("x")
with open(os.path.join(_ROOT, "racine.txt"), "w") as f:
f.write("y")
tree = client.get("/api/files", params={"project": "scoped"}).json()["tree"]
names = [n["name"] for n in tree]
assert "a.txt" in names and "racine.txt" not in names
def test_files_projet_invalide_400():
assert client.get("/api/files", params={"project": "../x"}).status_code == 400
-96
View File
@@ -1,96 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import router # noqa: E402
from app.routes.chat import ( # noqa: E402
_mentioned_files,
_prev_was_code,
_session_code_context,
_workspace_listing,
)
from app.config import settings # noqa: E402
def test_message_code_explicite():
assert router.is_code_task("crée une page html avec un formulaire") is True
def test_message_banal_pas_code():
assert router.is_code_task("quelle heure est-il ?") is False
def test_followup_courts():
# Suites typiques d'un travail de code, sans vocabulaire code explicite.
assert router.is_code_followup("ajoute un bouton rouge en haut") is True
assert router.is_code_followup("continue") is True
assert router.is_code_followup("enlève le menu et agrandis le titre") is True
assert router.is_code_followup("merci beaucoup !") is False
def test_prev_was_code_via_engine():
history = [
{"role": "user", "content": "crée un site", "meta": None},
{"role": "assistant", "content": "fait", "meta": {"engine": "code"}},
]
assert _prev_was_code(history) is True
def test_prev_was_code_via_write_file():
history = [
{"role": "assistant", "content": "ok",
"meta": {"tools": [{"name": "write_file", "args": {}}]}},
]
assert _prev_was_code(history) is True
def test_prev_was_code_discussion():
history = [
{"role": "assistant", "content": "voici la réponse", "meta": None},
]
assert _prev_was_code(history) is False
def test_mentioned_files_existants_seulement():
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "index.html"), "w", encoding="utf-8") as f:
f.write("<html></html>")
found = _mentioned_files("modifie index.html et style.css")
assert found == ["index.html"] # style.css n'existe pas
def test_session_code_context_recap_et_fichiers():
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "jeu.html"), "w", encoding="utf-8") as f:
f.write("<html>jeu</html>")
history = [
{"role": "user", "content": "crée un jeu snake dans jeu.html",
"meta": None},
{"role": "assistant", "content": "fait",
"meta": {"engine": "code",
"tools": [{"name": "write_file",
"args": {"path": "jeu.html"}}]}},
{"role": "user", "content": "il y a des bugs", "meta": None},
]
recap, files = _session_code_context(history)
assert "crée un jeu snake" in recap
assert files == ["jeu.html"]
def test_session_code_context_vide_sans_historique():
recap, files = _session_code_context([])
assert recap == ""
assert files == []
def test_workspace_listing_contient_fichiers():
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "jeu.html"), "w", encoding="utf-8") as f:
f.write("x")
listing = _workspace_listing()
assert "jeu.html" in listing
-43
View File
@@ -1,43 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import tools # noqa: E402
from app.config import settings # noqa: E402
# Le workspace effectif peut avoir été fixé par un autre fichier de test
# importé avant celui-ci : on écrit là où les outils lisent réellement.
_WS = os.path.abspath(settings.workspace_dir)
os.makedirs(_WS, exist_ok=True)
def _write(name: str, content: str) -> str:
path = os.path.join(_WS, name)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return name
def test_python_valide():
rel = _write("ok.py", "x = 1\nprint(x)\n")
assert tools.run_check(rel)["ok"] is True
def test_python_casse():
rel = _write("ko.py", "def broken(:\n")
result = tools.run_check(rel)
assert result["ok"] is False
assert result["issues"]
def test_json_casse():
rel = _write("ko.json", "{invalid")
assert tools.run_check(rel)["ok"] is False
def test_fichier_inconnu_type():
rel = _write("notes.txt", "bonjour")
result = tools.run_check(rel)
assert result["ok"] is True # type non vérifiable = pas d'erreur
-26
View File
@@ -1,26 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import skills # noqa: E402
def test_five_skills_loaded():
assert len(skills.ALL_SKILLS) == 5
assert all(s["title"] and s["body"] for s in skills.ALL_SKILLS.values())
def test_bug_message_picks_debug():
s = skills.pick_skill("mon script plante avec une erreur TypeError au démarrage")
assert s and s["name"] == "debogage-systematique"
def test_web_message_picks_web():
s = skills.pick_skill("crée une page html responsive pour ma boutique")
assert s and s["name"] == "creation-web"
def test_banal_message_picks_nothing():
assert skills.pick_skill("bonjour, quelle heure est-il ?") is None
-69
View File
@@ -1,69 +0,0 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import tools # noqa: E402
from app.config import settings # noqa: E402
_WS = os.path.abspath(settings.workspace_dir)
os.makedirs(_WS, exist_ok=True)
# ── read_file fenêtré ────────────────────────────────────────────────────
def _write(name: str, content: str) -> str:
with open(os.path.join(_WS, name), "w", encoding="utf-8") as f:
f.write(content)
return name
def test_read_file_petit_entier():
rel = _write("petit.txt", "a\nb\nc\n")
result = tools.read_file(rel)
assert result["content"] == "a\nb\nc"
assert "3 lignes" in result["summary"]
def test_read_file_gros_fenetre():
rel = _write("gros.txt", "\n".join(f"ligne {i}" for i in range(1, 501)))
result = tools.read_file(rel)
assert "ligne 200" in result["content"]
assert "ligne 201" not in result["content"].replace("start_line=201", "")
assert "start_line=201" in result["content"] # marche à suivre
assert "1-200 sur 500" in result["summary"]
def test_read_file_fenetre_suivante():
rel = _write("gros2.txt", "\n".join(f"ligne {i}" for i in range(1, 501)))
result = tools.read_file(rel, start_line=201)
assert "ligne 201" in result["content"]
assert "201-400 sur 500" in result["summary"]
# ── sortie shell filtrée ─────────────────────────────────────────────────
def test_compact_succes_garde_la_fin():
out = "\n".join(f"étape {i}" for i in range(1, 101))
compact = tools._compact_output(out, 0)
assert "étape 100" in compact
assert "étape 1\n" not in compact
assert "lignes omises" in compact
def test_compact_echec_garde_les_erreurs():
out = "\n".join(
["compilation démarrée"]
+ [f"module {i} ok" for i in range(50)]
+ ["ERROR: variable x undefined", "build failed"]
)
compact = tools._compact_output(out, 1)
assert "ERROR: variable x undefined" in compact
assert "build failed" in compact
assert "module 3 ok" not in compact
def test_dedupe_repetitions():
lines = ["warn: deprecated"] * 5 + ["fin"]
assert tools._dedupe_lines(lines) == ["warn: deprecated ×5", "fin"]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+17
View File
@@ -0,0 +1,17 @@
// Point d'entrée du binaire loki. Tout le code vit dans internal/loki ; ce
// dossier ne porte que le main() et les ressources Windows (.syso, icône,
// versioninfo) qui doivent résider dans le dossier du package main.
//
// Les directives ci-dessous embarquent les métadonnées Windows (éditeur, version,
// description) dans le .exe pour réduire les faux positifs antivirus. Régénère
// les .syso après avoir bumpé la version : `go generate ./...`
// (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest)
//
//go:generate go run github.com/R0m1k3/Loki/tools/gen-icon icon.ico
//go:generate goversioninfo -64 -icon=icon.ico -o resource_windows_amd64.syso versioninfo.json
//go:generate goversioninfo -64 -arm -icon=icon.ico -o resource_windows_arm64.syso versioninfo.json
package main
import "github.com/R0m1k3/Loki/internal/loki"
func main() { loki.Main() }
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 12,
"Patch": 3,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 12,
"Patch": 3,
"Build": 0
},
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "040004",
"FileType": "01",
"FileSubType": "00"
},
"StringFileInfo": {
"CompanyName": "Loki contributors",
"FileDescription": "Loki — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés",
"InternalName": "loki",
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.12.3",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
"Translation": {
"LangID": "040C",
"CharsetID": "04B0"
}
}
}
View File
Whitespace-only changes.
+26 -29
View File
@@ -1,45 +1,42 @@
# ─────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────
# Loki — docker-compose pour Unraid (image préconstruite par GitHub) # Loki — docker-compose pour Unraid (image préconstruite par GitHub Actions)
# #
# Aucun build ni git nécessaire sur Unraid : l'image est publiée automatiquement # Loki est un fork conteneurisé d'AJEAN (github.com/nathaninline/ajean, MIT) :
# sur GHCR par GitHub Actions à chaque push sur main. # moteur llama.cpp CUDA + interface web dans UN conteneur autonome.
# Aucun build ni git nécessaire sur Unraid — l'image est publiée sur GHCR à
# chaque push sur main.
# #
# MISE EN PLACE : # MISE EN PLACE :
# 1) Crée les dossiers de données (terminal Unraid) : # 1) Plugin « Nvidia Driver » (Apps) installé, GPU visible (nvidia-smi).
# mkdir -p /mnt/user/appdata/loki/workspace /mnt/user/appdata/loki/data # 2) Crée les dossiers de données (terminal Unraid) :
# 2) Plugin « Compose Manager » -> nouvelle stack -> colle ce fichier. # mkdir -p /mnt/user/appdata/loki/data /mnt/user/appdata/loki/models
# 3) Adapte OLLAMA_HOST avec l'IP de ton serveur Unraid (Ollama sur :11434). # 3) Plugin « Compose Manager » -> nouvelle stack -> colle ce fichier.
# 4) Compose Up. Interface : http://<ip-unraid>:8717 # 4) Compose Up. Interface : http://<ip-unraid>:8090
# #
# Mise à jour : Compose Down puis Up (l'option « pull image » récupère la # Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
# dernière version), ou « docker compose pull » avant Up. # ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
# #
# NB : si l'image ghcr.io/r0m1k3/loki est privée, rends-la publique une fois # Mise à jour : Compose Down puis Up avec « pull image », ou
# (GitHub -> Packages -> loki -> Package settings -> Change visibility), # « docker compose pull » avant Up.
# ou connecte Unraid à GHCR avec un token.
# ─────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────
services: services:
loki: loki:
image: ghcr.io/r0m1k3/loki:latest image: ghcr.io/r0m1k3/loki:latest
container_name: loki container_name: loki
runtime: nvidia
ports: ports:
# Un seul numéro de port, identique dedans/dehors (évite tout décalage). # Pour changer de port : modifie 8090 à gauche ET LOKI_WEB_PORT… ou
# Pour changer de port : modifie 8717 AUX DEUX endroits + PORT ci-dessous. # seulement à gauche (ex. "8717:8090") si tu veux garder l'ancien port.
- "8717:8717" - "8090:8090"
environment: environment:
# >>> À ADAPTER : IP de ton serveur Unraid (Ollama écoute sur :11434) <<< - NVIDIA_VISIBLE_DEVICES=all
- OLLAMA_HOST=http://192.168.1.10:11434 # Optionnel : modèle initial (sinon, choisis-le dans l'UI au premier
- DEFAULT_MODEL=gemma4:12b # lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
- WORKSPACE_DIR=/workspace # fichier dans /mnt/user/appdata/loki/models.
- DATA_DIR=/data - LOKI_MODEL=
- PORT=8717
# Optionnel : instance SearxNG pour l'outil web_search (sinon DuckDuckGo)
- SEARX_URL=
volumes: volumes:
- /mnt/user/appdata/loki/workspace:/workspace # /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
- /mnt/user/appdata/loki/data:/data - /mnt/user/appdata/loki/data:/data
extra_hosts: # /models : dossier de .gguf déposés à la main (gros fichiers)
# Permet aussi OLLAMA_HOST=http://host.docker.internal:11434 - /mnt/user/appdata/loki/models:/models
- "host.docker.internal:host-gateway"
# Le HEALTHCHECK est défini dans l'image (utilise automatiquement le bon port).
restart: unless-stopped restart: unless-stopped
+22 -40
View File
@@ -1,47 +1,32 @@
# Loki — fork conteneurisé d'AJEAN (https://github.com/nathaninline/ajean, MIT)
# Un seul service : le conteneur embarque le moteur llama.cpp (CUDA) ET l'UI.
#
# cp .env.example .env # ajuste si besoin
# docker compose up --build # build local en quelques minutes (llama-server
# # vient précompilé de l'image officielle llama.cpp)
#
# Interface : http://localhost:8090
# Pré-requis GPU : NVIDIA Container Toolkit sur l'hôte.
services: services:
loki: loki:
build: . build:
context: .
# Pour épingler la version du moteur ou passer en CPU/Vulkan :
# args: { LLAMACPP_IMAGE: "ghcr.io/ggml-org/llama.cpp:server-cuda-b10423" }
container_name: loki container_name: loki
ports: ports:
# Un seul numéro de port, identique dedans/dehors. - "${LOKI_WEB_PORT:-8090}:${LOKI_WEB_PORT:-8090}"
- "${PORT:-8717}:${PORT:-8717}"
environment: environment:
- OLLAMA_HOST=${OLLAMA_HOST:-http://host.docker.internal:11434} - LOKI_WEB_PORT=${LOKI_WEB_PORT:-8090}
- DEFAULT_MODEL=${DEFAULT_MODEL:-gemma4:12b} # Semé UNE fois au premier démarrage (modifiable ensuite dans l'UI) :
- WORKSPACE_DIR=/workspace - LOKI_MODEL=${LOKI_MODEL:-}
- DATA_DIR=/data - LOKI_CTX=${LOKI_CTX:-}
- PORT=${PORT:-8717} - LOKI_NGL=${LOKI_NGL:-}
# Optionnel : instance SearxNG pour web_search (sinon DuckDuckGo)
- SEARX_URL=${SEARX_URL:-}
volumes: volumes:
- ./workspace:/workspace # /data = LOKI_HOME : config, base bbolt, mémoire, workspace, modèles téléchargés
- ./data:/data - ./data:/data
extra_hosts: # /models : dossier(s) de modèles GGUF supplémentaires (LOKI_MODEL_DIRS)
# Permet d'atteindre un Ollama installé sur la machine hôte - ./models:/models
- "host.docker.internal:host-gateway"
# Le HEALTHCHECK est défini dans l'image (utilise automatiquement $PORT).
restart: unless-stopped
# ── Ollama optionnel ──────────────────────────────────────────────────
# Par défaut, Loki se connecte à un Ollama déjà présent sur l'hôte.
# Pour embarquer Ollama dans la stack, lance : docker compose --profile ollama up
# et règle OLLAMA_HOST=http://ollama:11434
ollama:
image: ollama/ollama:latest
container_name: loki-ollama
profiles: ["ollama"]
environment:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- OLLAMA_FLASH_ATTENTION=1
- OLLAMA_KV_CACHE_TYPE=q8_0
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
# Accès GPU NVIDIA : sans ceci, l'Ollama embarqué tourne en CPU (très lent).
# Nécessite le NVIDIA Container Toolkit installé sur l'hôte. Si tu n'as pas
# de GPU NVIDIA, commente tout le bloc `deploy:` ci-dessous.
deploy: deploy:
resources: resources:
reservations: reservations:
@@ -50,6 +35,3 @@ services:
count: all count: all
capabilities: ["gpu"] capabilities: ["gpu"]
restart: unless-stopped restart: unless-stopped
volumes:
ollama-models:
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
set -eu
: "${LOKI_HOME:=/data}"
export LOKI_HOME # les sous-commandes loki doivent voir le MEME dossier
mkdir -p "$LOKI_HOME"
# Garde-fou persistance : si /data n'est pas un point de montage, tout ce que
# l'utilisateur fera (modèles, discussions, fichiers) mourra avec le conteneur.
# On le dit au premier écran du journal — l'UI l'affiche aussi en bandeau.
if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] ⚠️ $LOKI_HOME n'est pas un volume monté : les données seront PERDUES à la recréation du conteneur."
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
seed() { # seed CLÉ VALEUR
[ -n "$2" ] || return 0
cur=$(loki config get "$1")
[ -n "$cur" ] || loki config set "$1=$2"
}
seed HOST "${LOKI_ENGINE_HOST:-127.0.0.1}"
seed PORT "${LOKI_ENGINE_PORT:-8080}"
seed MODEL "${LOKI_MODEL:-}"
seed CTX "${LOKI_CTX:-}"
seed NGL "${LOKI_NGL:-}"
# Moteur : démarrage best-effort. Sans MODEL configuré, on laisse l'UI monter
# quand même — le moteur partira au premier modèle choisi/téléchargé dans l'UI.
if [ -n "$(loki config get MODEL)" ]; then
loki start || echo "[entrypoint] moteur non démarré (voir loki logs) — l'UI reste accessible"
else
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
fi
exec loki web "${LOKI_WEB_PORT:-8090}"
File diff suppressed because it is too large. Load diff
@@ -1,954 +0,0 @@
# Projets + aperçu réductible — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** l'agent travaille dans des projets (sous-dossiers du workspace) choisis par session depuis le composer, et le panneau d'aperçu se replie.
**Architecture:** une contextvar dans `tools.py` re-racine `_safe_path`/`_workspace_root` sur `workspace/<projet>` pour toute la requête (chat, outils, shell, Aider) ; les routes fichiers/git prennent un paramètre `project` optionnel et posent la même contextvar. La session porte son projet (colonne SQLite). Frontend : chip 📁 dans le composer, store re-racine les appels fichiers.
**Tech Stack:** FastAPI, SQLite (migrations douces ALTER TABLE), contextvars, React/zustand.
**Spec:** `docs/superpowers/specs/2026-07-19-projets-apercu-design.md`
## Global Constraints
- Nom de projet : `^[a-z0-9][a-z0-9_-]{0,40}$` — sinon 400.
- Session sans projet (NULL) = racine du workspace, comportement actuel intact.
- Confinement `_safe_path` inchangé dans sa logique (re-raciné seulement).
- Un dépôt git PAR projet (`ensure_git` à la création et au premier usage git).
- Projet disparu du disque → chat retombe sur la racine + notice SSE.
- UI française, styles existants. Tests : `python -m pytest` depuis `backend/`.
- Après chaque tâche frontend : `cd frontend && npx tsc -b` passe.
---
### Task 1: Racine active (contextvar) + colonne project + routes sessions
**Files:**
- Modify: `backend/app/tools.py` (contextvar + validation nom)
- Modify: `backend/app/db.py` (migration + create/set project)
- Modify: `backend/app/routes/sessions.py` (create + PATCH avec project)
- Test: `backend/tests/test_projects.py`
**Interfaces:**
- Produces: `tools.set_project(name: str | None) -> None` (valide le nom, pose la contextvar ; `ToolError` si nom invalide) ; `tools.active_root() -> str` (racine effective, créée si absente) ; `tools.PROJECT_NAME` (regex compilée) ; `db.create_session(title, model, project=None)` ; `db.set_session_project(sid, project: str | None)` ; PATCH `/api/sessions/{sid}` accepte `{title?, project?}` (`project: ""` = retour racine).
- [ ] **Step 1: Écrire les tests qui échouent**
`backend/tests/test_projects.py` :
```python
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import db, tools # noqa: E402
from app.config import settings # noqa: E402
db.init_db()
_ROOT = os.path.abspath(settings.workspace_dir)
@pytest.fixture(autouse=True)
def _reset_project():
tools.set_project(None)
yield
tools.set_project(None)
def test_racine_par_defaut():
assert tools.active_root() == _ROOT
def test_set_project_reracine():
tools.set_project("demo")
root = tools.active_root()
assert root == os.path.join(_ROOT, "demo")
assert os.path.isdir(root) # créée à la volée
def test_confinement_conserve():
tools.set_project("demo")
with pytest.raises(tools.ToolError):
tools._safe_path("../hors-projet")
def test_nom_projet_invalide():
for bad in ("../x", "UPPER", "a b", "", "x" * 50):
with pytest.raises(tools.ToolError):
tools.set_project(bad)
def test_session_porte_son_projet():
s = db.create_session("t", None, project="demo")
assert db.get_session(s["id"])["project"] == "demo"
db.set_session_project(s["id"], None)
assert db.get_session(s["id"])["project"] is None
```
- [ ] **Step 2: Vérifier l'échec**
Run: `cd backend && python -m pytest tests/test_projects.py -q`
Expected: FAIL — `AttributeError: module 'app.tools' has no attribute 'set_project'`
- [ ] **Step 3: tools.py — contextvar**
En tête de `backend/app/tools.py` (après l'import `settings`) :
```python
from contextvars import ContextVar
# Projet actif pour la requête en cours : re-racine tous les outils sur
# workspace/<projet>. None = racine du workspace (comportement historique).
_ACTIVE_PROJECT: ContextVar[str | None] = ContextVar("loki_project", default=None)
PROJECT_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$")
def set_project(name: str | None) -> None:
"""Fixe le projet actif de la requête (None = racine)."""
if name is not None and not PROJECT_NAME.match(name):
raise ToolError(f"nom de projet invalide : {name!r}")
_ACTIVE_PROJECT.set(name)
def active_root() -> str:
"""Racine effective (workspace ou projet), créée si nécessaire."""
return _workspace_root()
```
Remplacer `_workspace_root` :
```python
def _workspace_root() -> str:
root = os.path.abspath(settings.workspace_dir)
project = _ACTIVE_PROJECT.get()
if project:
root = os.path.join(root, project)
os.makedirs(root, exist_ok=True)
return root
```
(`_safe_path` ne change pas : il s'appuie sur `_workspace_root()`.)
- [ ] **Step 4: db.py — colonne + accesseurs**
Dans `init_db`, à côté de la migration `summary` :
```python
if "project" not in scols:
conn.execute("ALTER TABLE sessions ADD COLUMN project TEXT")
```
`create_session` — ajouter le paramètre et la colonne (adapter l'INSERT
existant) :
```python
def create_session(title: str, model: str | None, project: str | None = None) -> dict:
```
et inclure `project` dans l'INSERT et le dict renvoyé. Ajouter :
```python
def set_session_project(sid: str, project: str | None) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET project = ? WHERE id = ?", (project, sid)
)
```
Vérifier que `list_sessions`/`get_session` renvoient la colonne (elles font
`dict(row)` — la colonne suit automatiquement).
- [ ] **Step 5: sessions.py — create + PATCH**
```python
class CreateSession(BaseModel):
title: str = "Nouvelle session"
model: str | None = None
project: str | None = None
class UpdateSession(BaseModel):
title: str | None = None
project: str | None = None # "" = retour à la racine du workspace
```
```python
@router.post("")
async def post_session(req: CreateSession) -> dict:
if req.project and not tools.PROJECT_NAME.match(req.project):
raise HTTPException(400, "nom de projet invalide")
return db.create_session(req.title, req.model, req.project or None)
@router.patch("/{sid}")
async def patch_session(sid: str, req: UpdateSession) -> dict:
if not db.get_session(sid):
raise HTTPException(404, "session introuvable")
if req.title is not None:
db.rename_session(sid, req.title)
if req.project is not None:
project = req.project or None
if project and not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
db.set_session_project(sid, project)
return {"ok": True}
```
(import : `from .. import db, tools`)
- [ ] **Step 6: Vérifier + commit**
Run: `cd backend && python -m pytest tests/ -q` — tout PASS.
```bash
git add backend/app/tools.py backend/app/db.py backend/app/routes/sessions.py backend/tests/test_projects.py
git commit -m "feat(projets): racine active par contextvar + projet par session"
```
---
### Task 2: Routes /api/projects + files/git re-racinés
**Files:**
- Create: `backend/app/routes/projects.py`
- Modify: `backend/app/routes/files.py` (param `project` sur les 4 routes)
- Modify: `backend/app/routes/git.py` (param `project` sur les 3 routes)
- Modify: `backend/app/main.py` (include projects.router)
- Test: `backend/tests/test_projects_routes.py`
**Interfaces:**
- Consumes: `tools.set_project`, `tools.active_root`, `tools.PROJECT_NAME`, `coder.ensure_git` (existant).
- Produces: `GET /api/projects` → `{projects: [{name, files}], root_files}` ; `POST /api/projects {name}` → `{name}` (201) ; toutes les routes files/git acceptent `?project=`.
- [ ] **Step 1: Tests qui échouent**
`backend/tests/test_projects_routes.py` :
```python
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from fastapi.testclient import TestClient # noqa: E402
from app import tools # noqa: E402
from app.main import app # noqa: E402
from app.config import settings # noqa: E402
client = TestClient(app)
_ROOT = os.path.abspath(settings.workspace_dir)
def test_creation_et_liste():
r = client.post("/api/projects", json={"name": "demo"})
assert r.status_code == 201
assert os.path.isdir(os.path.join(_ROOT, "demo", ".git"))
names = [p["name"] for p in client.get("/api/projects").json()["projects"]]
assert "demo" in names
def test_nom_invalide_400():
assert client.post("/api/projects", json={"name": "../x"}).status_code == 400
assert client.post("/api/projects", json={"name": "Demo"}).status_code == 400
def test_existant_400():
client.post("/api/projects", json={"name": "dup"})
assert client.post("/api/projects", json={"name": "dup"}).status_code == 400
def test_files_re_racine():
client.post("/api/projects", json={"name": "scoped"})
with open(os.path.join(_ROOT, "scoped", "a.txt"), "w") as f:
f.write("x")
with open(os.path.join(_ROOT, "racine.txt"), "w") as f:
f.write("y")
tree = client.get("/api/files", params={"project": "scoped"}).json()["tree"]
names = [n["name"] for n in tree]
assert "a.txt" in names and "racine.txt" not in names
def test_files_projet_invalide_400():
assert client.get("/api/files", params={"project": "../x"}).status_code == 400
```
Run: `cd backend && python -m pytest tests/test_projects_routes.py -q`
Expected: FAIL (404 sur /api/projects).
Note : `TestClient` requiert `httpx` (déjà présent). Si `starlette` réclame
un extra, `python -m pip install "httpx<1"` est déjà satisfait.
- [ ] **Step 2: routes/projects.py**
```python
"""Routes des projets : sous-dossiers de premier niveau du workspace."""
from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import coder, tools
from ..config import settings
router = APIRouter(prefix="/api/projects", tags=["projects"])
def _root() -> str:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return root
def _count_files(path: str) -> int:
total = 0
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
total += sum(1 for f in filenames if not f.startswith("."))
return total
@router.get("")
async def list_projects() -> dict:
root = _root()
projects = []
root_files = 0
for name in sorted(os.listdir(root)):
full = os.path.join(root, name)
if name.startswith("."):
continue
if os.path.isdir(full):
projects.append({"name": name, "files": _count_files(full)})
else:
root_files += 1
return {"projects": projects, "root_files": root_files}
class CreateProject(BaseModel):
name: str
@router.post("", status_code=201)
async def create_project(req: CreateProject) -> dict:
name = req.name.strip()
if not tools.PROJECT_NAME.match(name):
raise HTTPException(400, "nom de projet invalide (a-z, 0-9, - et _)")
target = os.path.join(_root(), name)
if os.path.exists(target):
raise HTTPException(400, "ce projet existe déjà")
os.makedirs(target)
# Dépôt git par projet : commits Aider + onglet Git propres au projet.
coder.ensure_git(target)
return {"name": name}
```
`main.py` : ajouter `projects` à l'import des routes et
`app.include_router(projects.router)`.
- [ ] **Step 3: files.py — param project**
Chaque route gagne `project: str | None = None` et commence par :
```python
try:
tools_mod.set_project(project or None)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
```
avec en tête du fichier `from .. import tools as tools_mod` (l'import existant
`from ..tools import ToolError, _safe_path` reste). Puis remplacer chaque
usage de `os.path.abspath(settings.workspace_dir)` par `tools_mod.active_root()` :
- `list_files` : `root = tools_mod.active_root()` (le `os.makedirs` est déjà
fait par `active_root`).
- `_tree` : signature `_tree(path: str, root: str)` — le `rel` se calcule
contre `root` passé en argument (adapter l'appel récursif et l'appel
depuis `list_files`).
- `delete_file` : le refus de racine devient
`if os.path.abspath(target) == tools_mod.active_root():`.
- `file_content` / `download_file` : seulement `set_project` en tête
(`_safe_path` suit la contextvar).
IMPORTANT : remettre `tools_mod.set_project(None)` n'est pas nécessaire —
la contextvar est par-tâche asyncio, chaque requête FastAPI a son contexte.
- [ ] **Step 4: git.py — param project**
`_git` gagne `project: str | None = None` :
```python
def _git(*args: str, timeout: int = 15, project: str | None = None) -> subprocess.CompletedProcess:
root = os.path.abspath(settings.workspace_dir)
if project:
if not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
root = os.path.join(root, project)
ensure_git(root)
return subprocess.run(
["git", *args], cwd=root, ...
)
```
(reprendre les kwargs existants de l'appel `subprocess.run` du fichier ;
imports : `from .. import tools`, `from fastapi import HTTPException` déjà
présent ou à ajouter, `import os`). Les trois routes (`git_log`, `git_diff`,
`git_revert`) gagnent `project: str | None = None` et le propagent à chaque
appel `_git(..., project=project)`.
- [ ] **Step 5: Vérifier + commit**
Run: `cd backend && python -m pytest tests/ -q` — tout PASS.
```bash
git add backend/app/routes/projects.py backend/app/routes/files.py backend/app/routes/git.py backend/app/main.py backend/tests/test_projects_routes.py
git commit -m "feat(projets): routes /api/projects + files et git re-racinés"
```
---
### Task 3: Chat re-raciné (contexte, Aider, notice projet disparu)
**Files:**
- Modify: `backend/app/routes/chat.py`
- Modify: `backend/app/coder.py` (`run_code_task` paramètre `root`)
- Modify: `backend/app/agent.py` (dispatch `code_task` passe la racine)
- Test: `backend/tests/test_projects.py` (ajout)
**Interfaces:**
- Consumes: `tools.set_project`, `tools.active_root` (Task 1).
- Produces: `coder.run_code_task(instruction, model, files=None, root: str | None = None)`.
- [ ] **Step 1: Test qui échoue (aides de contexte re-racinées)**
Ajouter à `backend/tests/test_projects.py` :
```python
def test_aides_contexte_suivent_le_projet():
from app.routes.chat import _mentioned_files, _workspace_listing
tools.set_project("ctxdemo")
root = tools.active_root()
with open(os.path.join(root, "app.py"), "w", encoding="utf-8") as f:
f.write("x = 1")
assert "app.py" in _workspace_listing()
assert _mentioned_files("corrige app.py") == ["app.py"]
tools.set_project(None)
assert _mentioned_files("corrige app.py") == [] # absent de la racine
```
Run: `cd backend && python -m pytest tests/test_projects.py -q`
Expected: FAIL — `_workspace_listing`/`_mentioned_files` lisent encore
`settings.workspace_dir`.
- [ ] **Step 2: chat.py — racine active partout**
Dans `chat()` juste après la récupération de la session :
```python
# Projet de la session : re-racine outils, shell, Aider et aides de
# contexte pour tout le tour. Projet disparu -> retour racine + notice.
project = session.get("project") or None
project_missing = False
if project:
proj_dir = os.path.join(os.path.abspath(settings.workspace_dir), project)
if not os.path.isdir(proj_dir):
project_missing, project = True, None
tools.set_project(project)
```
avec `from .. import tools` dans les imports du fichier. Dans
`event_stream()`, après le `yield _sse("start", …)` :
```python
if project_missing:
yield _sse("notice", {"message":
"Projet de la session introuvable sur le disque — retour au workspace."})
```
Remplacer dans `_session_code_context`, `_workspace_listing` et
`_mentioned_files` chaque `root = os.path.abspath(settings.workspace_dir)`
par `root = tools.active_root()`.
Chemin code : passer la racine à Aider —
```python
async for chunk in _code_stream(
req, code_model, extra=extra, plan=plan, files=code_files,
):
```
`_code_stream` → `_run_aider_keepalive(instruction, model, files)` →
```python
task = asyncio.create_task(
asyncio.to_thread(
coder.run_code_task, instruction, model, files, tools.active_root()
)
)
```
(`active_root()` évalué AVANT le to_thread : la contextvar ne suit pas dans
le thread.)
- [ ] **Step 3: coder.py — paramètre root**
```python
def run_code_task(
instruction: str,
model: str,
files: list[str] | None = None,
root: str | None = None,
) -> dict:
```
et remplacer `root = os.path.abspath(settings.workspace_dir)` par
`root = os.path.abspath(root or settings.workspace_dir)` (le reste — ensure_git,
chdir, fnames — utilise déjà `root`).
- [ ] **Step 4: agent.py — code_task scoped**
Dans le dispatch `code_task` de `run_agent`, remplacer l'appel :
```python
if name == "code_task":
from .tools import active_root
code_model = await coder.pick_code_model(model)
result = await asyncio.to_thread(
coder.run_code_task,
args.get("instruction", ""),
code_model,
args.get("files") or [],
active_root(),
)
```
- [ ] **Step 5: Vérifier + commit**
Run: `cd backend && python -m pytest tests/ -q` — tout PASS ;
`python -m compileall -q app`.
```bash
git add backend/app/routes/chat.py backend/app/coder.py backend/app/agent.py backend/tests/test_projects.py
git commit -m "feat(projets): chat, aides de contexte et Aider suivent le projet de la session"
```
---
### Task 4: Frontend — API client + store projets
**Files:**
- Modify: `frontend/src/api/client.ts`
- Modify: `frontend/src/store/useStore.ts`
**Interfaces:**
- Consumes: routes Tasks 1-2.
- Produces: `listProjects(): Promise<{projects: {name: string; files: number}[]; root_files: number}>` ; `createProject(name): Promise<void>` ; `setSessionProject(id, project: string | null): Promise<void>` ; store : `currentProject(): string | null` (getter), `setProject(name: string | null)`, `projects: {name: string; files: number}[]`, `refreshProjects()`.
- [ ] **Step 1: client.ts**
Type `Session` : ajouter `project?: string | null;`. Ajouter :
```typescript
export async function listProjects(): Promise<{
projects: { name: string; files: number }[];
root_files: number;
}> {
const res = await fetch("/api/projects");
return res.json();
}
export async function createProject(name: string): Promise<void> {
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw await apiError(res, "création du projet impossible");
}
export async function setSessionProject(
id: string,
project: string | null
): Promise<void> {
const res = await fetch(`/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project: project ?? "" }),
});
if (!res.ok) throw await apiError(res, "changement de projet impossible");
}
```
Re-raciner les appels fichiers — signatures :
```typescript
const projQuery = (project?: string | null) =>
project ? `&project=${encodeURIComponent(project)}` : "";
export async function listFiles(project?: string | null): Promise<FileNode[]> {
const query = project ? `?project=${encodeURIComponent(project)}` : "";
const res = await fetch(`/api/files${query}`);
return (await res.json()).tree;
}
export async function fileContent(
path: string,
project?: string | null
): Promise<string> {
const res = await fetch(
`/api/files/content?path=${encodeURIComponent(path)}${projQuery(project)}`
);
if (!res.ok) return "";
return (await res.json()).content;
}
export function downloadFile(path: string, project?: string | null): void {
const link = document.createElement("a");
link.href = `/api/files/download?path=${encodeURIComponent(path)}${projQuery(project)}`;
link.download = path.split(/[\\/]/).pop() ?? "fichier";
document.body.appendChild(link);
link.click();
link.remove();
}
export async function deleteFile(
path: string,
project?: string | null
): Promise<void> {
const res = await fetch(
`/api/files?path=${encodeURIComponent(path)}${projQuery(project)}`,
{ method: "DELETE" }
);
if (!res.ok) {
throw await apiError(res, `suppression impossible (${res.status})`);
}
}
```
Idem routes git utilisées par PreviewPanel (`getGitLog`, `getGitDiff`,
`revertCommit`) : paramètre optionnel `project` ajouté en query string de la
même façon.
`createSession` : paramètre `project?: string | null` transmis dans le body.
- [ ] **Step 2: useStore.ts**
État : `projects: { name: string; files: number }[]` (init `[]`). Actions :
```typescript
refreshProjects: async () => {
try {
const { projects } = await listProjects();
set({ projects });
} catch { /* backend indisponible */ }
},
currentProject: () => {
const s = get().sessions.find((x) => x.id === get().currentSessionId);
return s?.project ?? null;
},
setProject: async (name) => {
let sid = get().currentSessionId;
if (!sid) {
const s = await createSession(get().selectedModel || undefined, name);
set({ currentSessionId: s.id, messages: [] });
sid = s.id;
} else {
await setSessionProject(sid, name);
}
await get().refreshSessions();
await get().refreshFiles();
set({ previewPath: null, previewContent: "" });
},
```
(`createSession(model?, project?)` : étendre la fonction client existante.)
`refreshFiles` passe le projet : `listFiles(get().currentProject())`.
`openPreview` : `fileContent(path, get().currentProject())`.
`removeFile` : `deleteFile(path, get().currentProject())`.
`newSession` : `createSession(model, get().currentProject())` — héritage.
`openSession` : après chargement, `await get().refreshFiles()` (l'arbre suit
la session ouverte).
Les composants qui appellent `downloadFile(path)` passent aussi
`useStore.getState().currentProject()` (LeftPanel, ActivityViews,
PreviewPanel).
Types : ajouter `projects`, `refreshProjects`, `currentProject`, `setProject`
à `LokiState`.
- [ ] **Step 3: Vérifier + commit**
Run: `cd frontend && npx tsc -b` — OK (les composants utilisant downloadFile
sont ajustés dans cette tâche pour compiler).
```bash
git add frontend/src/api/client.ts frontend/src/store/useStore.ts frontend/src/panels/LeftPanel.tsx frontend/src/panels/ActivityViews.tsx frontend/src/panels/PreviewPanel.tsx
git commit -m "feat(projets): client API + store re-racinés par projet"
```
---
### Task 5: Chip projet dans le composer + aperçu réductible
**Files:**
- Create: `frontend/src/components/ProjectChip.tsx`
- Modify: `frontend/src/panels/ChatPanel.tsx` (rendu du chip à côté du ModeSelector)
- Modify: `frontend/src/panels/PreviewPanel.tsx` (repli)
**Interfaces:**
- Consumes: store Task 4 (`projects`, `refreshProjects`, `currentProject`, `setProject`).
- [ ] **Step 1: ProjectChip.tsx**
```tsx
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { createProject } from "../api/client";
/** Sélecteur de projet du composer : 📁 <projet> + menu (liste, création). */
export function ProjectChip() {
const { projects, refreshProjects, currentProject, setProject } = useStore();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const active = currentProject();
useEffect(() => {
if (open) void refreshProjects();
}, [open, refreshProjects]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
setError(null);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const choose = async (name: string | null) => {
setOpen(false);
await setProject(name);
};
const create = async () => {
const name = draft.trim();
if (!name) return;
try {
await createProject(name);
setCreating(false);
setDraft("");
await choose(name);
} catch (err) {
setError(err instanceof Error ? err.message : "création impossible");
}
};
return (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex h-8 items-center gap-1.5 border-2 border-line bg-base px-2.5 text-[12px] text-ink-2"
title="Projet de travail de cette session"
>
📁 <span className="max-w-[140px] truncate">{active ?? "workspace"}</span>
</button>
{open && (
<div
className="absolute bottom-[calc(100%+6px)] left-0 z-30 w-[240px] border-[3px] border-line bg-card shadow-hard"
style={{ borderRadius: 7 }}
>
<button
onClick={() => choose(null)}
className={`block w-full px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === null ? "bg-base font-bold" : ""
}`}
>
workspace (racine)
</button>
{projects.map((p) => (
<button
key={p.name}
onClick={() => choose(p.name)}
className={`block w-full border-t-2 border-line-soft px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === p.name ? "bg-base font-bold" : ""
}`}
>
📁 {p.name}
<span className="ml-1.5 text-[11px] text-muted-2">
{p.files} fichier{p.files > 1 ? "s" : ""}
</span>
</button>
))}
<div className="border-t-2 border-line-soft p-2">
{creating ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void create();
if (e.key === "Escape") {
setCreating(false);
setError(null);
}
}}
placeholder="nom-du-projet"
className="w-full border-2 border-line bg-base px-2 py-1 text-[12px] text-ink outline-none"
/>
) : (
<button
onClick={() => setCreating(true)}
className="w-full text-left text-[13px] font-bold text-accent"
>
+ Nouveau projet
</button>
)}
{error && <div className="mt-1 text-[11px] text-warn">{error}</div>}
</div>
</div>
)}
</div>
);
}
```
- [ ] **Step 2: ChatPanel — rendu**
Localiser le rendu du sélecteur de mode dans le composer (composant local
`ModeSelector`, utilisé vers le bas du composer). Ajouter `<ProjectChip />`
juste à côté (même conteneur flex), avec
`import { ProjectChip } from "../components/ProjectChip";`.
- [ ] **Step 3: PreviewPanel — repli**
Dans `PreviewPanel` :
```tsx
const [collapsed, setCollapsed] = useState(
() => window.localStorage.getItem("loki.preview.collapsed") === "1"
);
const toggleCollapsed = () => {
setCollapsed((c) => {
window.localStorage.setItem("loki.preview.collapsed", c ? "0" : "1");
return !c;
});
};
```
Rendu replié — AVANT le rendu normal :
```tsx
if (collapsed) {
return (
<div className="flex w-9 flex-none flex-col items-center border-l-[3px] border-line bg-panel pt-3">
<button
onClick={toggleCollapsed}
title="Déplier l'aperçu"
className="text-[15px] text-muted-2 hover:text-accent"
>
⇤
</button>
<div className="mt-3 rotate-90 whitespace-nowrap text-[10px] font-bold tracking-wide text-muted-3">
APERÇU
</div>
</div>
);
}
```
Bouton de repli dans la barre d'onglets existante (à droite des Tabs) :
```tsx
<div className="flex-1" />
<button
onClick={toggleCollapsed}
title="Replier l'aperçu"
className="px-2 text-[15px] text-muted-2 hover:text-accent"
>
⇥
</button>
```
(replié = composant retourne tôt → aucun contenu/iframe monté, conforme spec.)
- [ ] **Step 4: Vérifier + commit**
Run: `cd frontend && npx tsc -b && npm run build` — OK.
```bash
git add frontend/src/components/ProjectChip.tsx frontend/src/panels/ChatPanel.tsx frontend/src/panels/PreviewPanel.tsx
git commit -m "feat(ui): chip projet dans le composer + aperçu réductible"
```
---
### Task 6: Vérification bout-en-bout + docs + push
**Files:**
- Modify: `README.md`
- [ ] **Step 1: README**
Section après « Panneau Git & Diff » :
```markdown
## Projets (répertoires de travail)
Chaque session peut travailler dans un **projet** : un sous-dossier du
workspace choisi via le chip 📁 du composer (« + Nouveau projet » pour en
créer un). L'agent, le shell, le moteur code et l'arborescence sont confinés
au projet ; chaque projet a son propre dépôt git (historique et revert
indépendants). Session sans projet = racine du workspace.
```
- [ ] **Step 2: Suite complète**
```bash
cd backend && python -m pytest tests/ -q && python -m compileall -q app
cd ../frontend && npx tsc -b && npm run build
```
- [ ] **Step 3: Smoke local (uvicorn, sans Ollama)**
```bash
cd backend && python -m uvicorn app.main:app --port 8199 & # env WORKSPACE_DIR/DATA_DIR temporaires
curl -s -X POST http://localhost:8199/api/projects -H "Content-Type: application/json" -d '{"name":"demo"}'
curl -s http://localhost:8199/api/projects
curl -s -X POST http://localhost:8199/api/projects -H "Content-Type: application/json" -d '{"name":"../x"}' -o /dev/null -w "%{http_code}" # 400
SID=$(curl -s -X POST http://localhost:8199/api/sessions -H "Content-Type: application/json" -d '{"title":"t","project":"demo"}' | python -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -s -X PATCH http://localhost:8199/api/sessions/$SID -H "Content-Type: application/json" -d '{"project":""}' # retour racine
curl -s "http://localhost:8199/api/files?project=demo"
```
Expected : création 201 + `.git` ; liste contient demo ; 400 sur nom
invalide ; PATCH ok ; arbre du projet vide (pas les fichiers racine).
- [ ] **Step 4: Commit + push**
```bash
git add README.md
git commit -m "docs: projets par session"
git push
```
Vérif UI après déploiement : chip 📁 → créer `demo` → message « crée
hello.txt » → fichier dans workspace/demo/ ; session B sans projet → racine ;
onglet Git montre l'historique du projet actif ; bouton ⇥ replie l'aperçu,
état conservé au rechargement.
File diff suppressed because it is too large. Load diff
@@ -1,135 +0,0 @@
# Loki — MCP intégré + Skills automatiques + Boucle code vérifiée
**Date** : 2026-07-18
**Objectif** : augmenter l'intelligence effective des modèles locaux (~17 Go,
répartis sur 5060 Ti + 3060 12 Go) sans changer de modèle, en améliorant le
harnais : outils professionnels via MCP, méthodes expertes injectées (skills),
et vérification réelle du code produit.
**Choix utilisateur validés** :
- Cas d'usage : tout (code + raisonnement + multi-étapes).
- Compromis : équilibré — passes supplémentaires uniquement pour le code
(1 passe de correction max).
- Un seul gros modèle ; pas d'architecture deux-modèles.
- MCP : catalogue préconfiguré des meilleurs serveurs, toggles on/off,
désactivé = zéro coût (aucun outil dans le prompt, aucun process lancé).
- Skills : déclenchement automatique (routeur lexical), invisible, désactivable.
---
## 1. Client MCP intégré
### Backend — `backend/app/mcp_client.py`
- SDK officiel Python `mcp` (ajout à `requirements.txt`).
- Transports : stdio (commande locale, ex. `npx …`) et streamable-http (URL).
- **Gestionnaire** (singleton, même pattern que `ollama_client`) :
- Catalogue embarqué (dict Python) + état activé/désactivé et paramètres
(clé API, URL) persistés en DB (`db.get/set_config_value`, clé `mcp`).
- Connexion **lazy** : un serveur activé n'est démarré qu'au premier message
qui suit ; la session MCP (process + handshake) est réutilisée ensuite.
- Serveur désactivé : process jamais lancé, outils jamais exposés.
- `list_tools()` par serveur → conversion schéma MCP → schéma
function-calling Ollama ; préfixe `mcp_<serveur>_<outil>`.
- `call_tool(name, args)` avec timeout 30 s par appel.
- Panne (crash process, handshake KO, timeout répété) : le serveur est
marqué en erreur, ses outils retirés, notice SSE dans le fil ; nouvelle
tentative au message suivant. Un MCP cassé ne bloque jamais le chat.
- `aclose()` au shutdown (lifespan `main.py`).
### Catalogue préconfiguré (désactivés par défaut)
| ID | Serveur | Commande / transport | Apport |
|---|---|---|---|
| `playwright` | Playwright MCP | `npx @playwright/mcp@latest --headless` | navigateur réel : naviguer, cliquer, lire la console, screenshot |
| `context7` | Context7 | `npx -y @upstash/context7-mcp` | doc à jour de toute librairie/framework |
| `fetch` | Fetch | `python -m mcp_server_fetch` | lecture propre d'URL (markdown) |
| `searxng` | SearxNG | `npx -y mcp-searxng` + `SEARXNG_URL` | vraie recherche web (nécessite instance SearxNG) |
| `custom` | Personnalisé | commande ou URL saisie par l'utilisateur | n'importe quel serveur MCP |
- Filtre d'outils par serveur (liste `expose` optionnelle dans le catalogue)
pour limiter le nombre d'outils injectés (ex. Playwright : navigate, click,
type, snapshot, console, screenshot — pas les 25+ outils complets).
### Intégration boucle agent — `backend/app/agent.py` + `tools.py`
- Les outils MCP actifs sont ajoutés à la liste d'outils envoyée au modèle
(après les outils natifs) et dispatchés vers `mcp_client.call_tool`.
- Résultats tronqués à une taille max (~8 000 caractères) avant retour au
modèle (protège le contexte).
- Rendu frontend : ToolCards existantes (aucun composant nouveau requis).
### UI — Configuration → onglet « MCP » (`SettingsView.tsx`)
- Une carte par serveur du catalogue : nom, description, toggle, statut
(● connecté / ○ inactif / ⚠ erreur + message), nombre d'outils exposés,
champs paramètres (URL/clé) si requis.
- Carte « Personnalisé » : champ commande ou URL + toggle.
- Routes : `GET /api/mcp` (état), `PUT /api/mcp/{id}` (toggle + params),
`POST /api/mcp/{id}/test` (connexion d'essai, renvoie outils découverts).
### Dockerfile
- Ajout Node.js LTS (requis npx) + `pip install mcp mcp-server-fetch`.
- Image : les serveurs npx sont téléchargés au premier lancement (cache
volume npm optionnel).
## 2. Skills automatiques
### Bibliothèque — `backend/skills/*.md`
Fichiers markdown en français, livrés avec l'app :
- `debogage-systematique.md` — reproduire → isoler → hypothèse → corriger → vérifier.
- `creation-web.md` — structure sémantique → style → interactivité → vérification (liens, console, responsive).
- `refactor-sur.md` — comprendre → tests/garde-fous → petits pas → vérifier à chaque pas.
- `analyse-donnees.md` — examiner le format → valider → transformer → présenter.
- `redaction-structuree.md` — plan → rédaction → relecture ciblée.
Format : frontmatter (`name`, `description`, `keywords`) + corps injecté tel quel.
### Routeur de skills — `backend/app/skills.py`
- Sélection **lexicale instantanée** (regex/mots-clés, même style que
`router.py`) : au plus UNE skill par message ; aucun appel LLM.
- Injection : message système supplémentaire
(`"Méthode à suivre pour cette tâche :\n<corps>"`) inséré après l'invite
système, pour ce tour uniquement (non persisté dans l'historique).
- Événement SSE `skill` `{name, title}` → badge dans le fil
(« 📘 Skill : Débogage systématique »).
- Toggle global `skills_enabled` (défaut : activé) dans la config agent
(Configuration → Intelligence).
## 3. Boucle code vérifiée
- Nouvel outil natif `run_check` (`tools.py`) : détection par extension —
`.py` → `python -m py_compile` (analyse seule, JAMAIS d'exécution — toute
exécution passe par `run_shell` et sa validation utilisateur) ;
`.js/.ts` → `node --check` ; `.html` → `check_html` existant ; JSON → parse.
Sortie = erreurs compactées.
- Boucle agent : après un `write_file`/`edit_file` sur du code, le harnais
exécute `run_check` automatiquement et renvoie les erreurs au modèle dans le
même tour — **1 passe de correction maximum** (choix « équilibré »).
- Si le serveur MCP Playwright est actif et que la tâche a produit du HTML :
la skill `creation-web` oriente le modèle vers une vérification réelle dans
le navigateur (console + rendu) au lieu du check statique seul.
## 4. Hors périmètre
- Architecture deux-modèles (architecte/codeur) — rejetée par l'utilisateur.
- Best-of-N / juge — trop coûteux.
- Marketplace/édition de skills dans l'UI (v2 possible ; v1 = fichiers livrés).
## 5. Vérification
1. **MCP** : activer Fetch → demander « résume cette page <url> » → ToolCard
`mcp_fetch_fetch` + résumé correct. Désactiver → l'outil disparaît du
prompt (vérifiable via logs). Serveur cassé (commande invalide) → notice,
chat fonctionnel.
2. **Playwright** : « crée une page X puis vérifie-la dans le navigateur » →
navigation + console lue + correction éventuelle.
3. **Skills** : message « mon script plante avec TypeError » → badge skill
débogage ; message banal → aucun badge.
4. **run_check** : demander un script Python avec bug volontaire induit →
l'erreur est détectée et corrigée dans le même tour (1 passe).
5. **Perf** : aucun serveur MCP actif → latence premier token inchangée
(mesure avant/après).
@@ -1,99 +0,0 @@
# Loki — Projets (répertoires de travail) + aperçu réductible
**Date** : 2026-07-19
**Objectif** : permettre à l'agent de travailler dans des projets entiers —
un projet = un répertoire choisi depuis la carte du chat — et pouvoir replier
le panneau d'aperçu.
**Choix validés** : projet = sous-dossier de premier niveau du workspace
(pas de chemin arbitraire) ; portée par session ; édition de la mémoire
écartée du périmètre.
---
## 1. Projets — backend
### Modèle
- Un projet = un sous-dossier de premier niveau de `WORKSPACE_DIR`
(ex. `workspace/jeu-snake/`). Nom validé : `^[a-z0-9][a-z0-9_-]{0,40}$`.
- Session sans projet (`NULL`) = racine du workspace — compatibilité totale
avec les sessions existantes.
- Colonne `project TEXT` ajoutée à `sessions` (migration douce
`ALTER TABLE`, comme `summary`/`meta`).
### Racine active par requête (contextvar)
- `tools.py` : `_ACTIVE_ROOT: ContextVar[str | None]` + fonctions
`set_project(name | None)` / lecture dans `_workspace_root()`. `_safe_path`
inchangé dans sa logique de confinement — simplement re-raciné sur
`workspace/<projet>` quand un projet est actif.
- `routes/chat.py` : au début de `chat()`, `tools.set_project(session["project"])`
(après validation : dossier existant, sinon retombe sur la racine + notice
SSE « projet introuvable, retour au workspace »).
- Les aides de contexte suivent la même racine : `_workspace_listing`,
`_mentioned_files`, `_session_code_context` (fichiers vérifiés sous la
racine active).
- `run_shell` : cwd = racine active (déjà `_workspace_root()`).
### Moteur code (Aider) par projet
- `coder.run_code_task` reçoit la racine active (cwd du process Aider).
- `coder.ensure_git(dir)` appelé à la création d'un projet → un dépôt git
par projet, historique/revert propres. L'onglet Git du panneau droit opère
sur le projet de la session courante (routes git prennent `project`).
### Routes
- `GET /api/projects` → `{projects: [{name, files: int}], root_files: int}`
(sous-dossiers de premier niveau, dotfiles exclus).
- `POST /api/projects {name}` → mkdir + `ensure_git` ; 400 si nom invalide
ou existant.
- `PATCH /api/sessions/{sid}` accepte `project: str | null` (en plus de
`title`).
- `GET /api/files`, `/api/files/content`, `/api/files/download`,
`DELETE /api/files` : paramètre optionnel `project` — même re-racinage,
même confinement.
- Routes git (`/api/git/*`) : paramètre optionnel `project`.
## 2. Sélecteur projet — frontend (carte du chat)
- **Chip « 📁 <projet> »** dans le composer, à côté du sélecteur de mode
Plan/Build/Yolo. Affiche `workspace` si aucun projet.
- Clic → menu (même style que les menus existants) : liste des projets,
entrée active cochée, **« + Nouveau projet »** avec input inline
(Enter crée + sélectionne, Escape annule).
- Sélection → `PATCH /api/sessions/{sid} {project}` → store met à jour la
session courante → `refreshFiles()` re-racine LeftPanel / FilesView /
PreviewPanel (le store passe `project` de la session courante aux appels
fichiers).
- Nouvelle session : hérite du projet de la session courante (envoyé au
`POST /api/sessions`).
- Session sans projet : comportement actuel inchangé.
## 3. Aperçu réductible — frontend
- Bouton de repli dans l'en-tête du `PreviewPanel` (à côté des onglets) :
replie le panneau en **barre verticale fine (36 px)** ne contenant qu'un
bouton de réouverture (icône ⇤ pivotée) et l'indicateur d'onglet actif.
- État replié persisté en `localStorage` (`loki.preview.collapsed`), la
largeur l'est déjà (`loki.preview.width`).
- Replié, le panneau ne rend pas son contenu (pas d'iframe HTML vivante).
## 4. Hors périmètre
- Édition de la mémoire (RAG/résumés) — écartée par l'utilisateur.
- Chemins hors workspace, suppression/renommage de projets depuis l'UI
(suppression possible via la corbeille de l'arborescence).
- Projet imbriqué (sous-sous-dossier comme projet).
## 5. Vérification
1. `POST /api/projects {"name":"demo"}` → dossier + `.git` créés ;
nom invalide → 400.
2. Session A projet `demo`, session B sans projet : fichiers créés par
l'agent de A atterrissent dans `workspace/demo/`, ceux de B à la racine ;
l'arborescence gauche suit la session ouverte.
3. `curl "…/api/files?project=demo"` → arbre du projet seul ;
`?project=../x` → 400.
4. Reprise : « corrige les bugs » dans une session projet → Aider travaille
dans `workspace/demo/`, commit dans le git du projet, onglet Git montre
l'historique du projet.
5. Aperçu : bouton replie → barre fine, contenu démonté ; réouverture
restaure l'onglet et la largeur ; état conservé après rechargement.
6. pytest : contextvar (re-racinage + confinement), routes projects,
migration colonne, héritage projet à la création de session.
@@ -0,0 +1,187 @@
# Dictée en temps réel, configurable selon le matériel
*Conception validée le 21 août 2026.*
## Le problème
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
défauts la rendent pénible :
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
un rechargement complet du modèle toutes les quelques secondes.
## Ce qu'on construit
Trois choses, dans cet ordre de dépendance :
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
## 1. Le moteur
### whisper-server plutôt que whisper-cli
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
Loki le supervise comme il supervise déjà `llama-server`.
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
modèle ; les suivants sont immédiats.
### Choix du GPU
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
pas de code à maintenir dans ggml :
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
- valeur vide → CPU seul
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
### Compilation : deux binaires, pas un
L'étape `whisperbuild` produit **deux** binaires :
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
CUDA condamnerait donc la variante CPU de l'image.
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
le disant, plutôt que de laisser la dictée morte.
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
## 2. Le découpage
### On coupe dans les silences, jamais à heure fixe
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
l'améliorer.
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
est borné par la coupure forcée.
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
pas voir le texte se figer.
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
dont personne ne connaît l'interaction :
| Réglage | Réserve minimale | Coupure forcée |
|---|---|---|
| Court | 1 s | 5 s |
| Moyen (défaut) | 1,5 s | 8 s |
| Long | 3 s | 15 s |
### La logique vit en Go
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
Et le navigateur redevient bête : capter, envoyer, afficher.
### Contrat de la socket
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
sous les yeux.
### Langue forcée par défaut
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
## 3. Le panneau Dictée
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
« Identité ». Suit le gabarit des panneaux existants.
| Réglage | Contenu |
|---|---|
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
| **Langue** | Français par défaut, auto et autres langues disponibles |
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
progression et suppression, en généralisant le mécanisme mono-modèle existant.
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
le jeton Hugging Face.
## Les pannes, nommées
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
d'échouer sec
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
l'utilisateur est prévenu
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
dans la mauvaise direction.
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
## Tests
En Go, sur ce qui peut réellement casser :
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
modèle, langue
- **l'aller-retour des réglages** dans `bkState`
- **l'extinction sur inactivité**
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
## Ce que les tests ne prouveront pas
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
échantillon de voix n'est disponible côté développement. Les tests établissent que le
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
`small` reste une constatation d'usage.
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

-19
View File
@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>Loki - Agent local</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-4273
View File
File diff suppressed because it is too large. Load diff
-28
View File
@@ -1,28 +0,0 @@
{
"name": "loki-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#18181b"/>
<rect x="14" y="14" width="38" height="38" rx="7" fill="#ff5436"/>
<rect x="11" y="11" width="38" height="38" rx="7" fill="#ff5436" stroke="#ffffff" stroke-width="6"/>
<rect x="25" y="25" width="12" height="12" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 358 B

-73
View File
@@ -1,73 +0,0 @@
import { useEffect, useState } from "react";
import { Sidebar, type View } from "./components/Sidebar";
import { TopBar } from "./components/TopBar";
import { ChatPanel } from "./panels/ChatPanel";
import { PreviewPanel } from "./panels/PreviewPanel";
import { SettingsView } from "./panels/SettingsView";
import { HistoryView, ToolsView } from "./panels/ActivityViews";
import { useStore } from "./store/useStore";
export default function App() {
const [view, setView] = useState<View>("chat");
const { refreshPulse, refreshModels, refreshSessions } = useStore();
// Sondage frugal. Trois principes, contre l'app qui chauffait le CPU à vide :
// - UNE seule requête groupée (/api/system/pulse) au lieu de trois ;
// - RIEN quand l'onglet est caché (l'utilisateur ne regarde pas) ;
// - cadence adaptative : réactif pendant le travail, lent au repos.
// Le serveur, lui, ne tourne jamais en boucle : sans navigateur, zéro CPU.
useEffect(() => {
refreshPulse();
refreshModels();
refreshSessions();
let timer: number | undefined;
const tick = () => {
// Onglet caché : on ne sonde pas du tout, on repassera au retour.
if (document.hidden) return schedule();
refreshPulse().finally(schedule);
};
const schedule = () => {
if (document.hidden) {
timer = window.setTimeout(schedule, 30000);
return;
}
// Pendant un flux, les indicateurs (VRAM, GPU) doivent suivre.
const busy = useStore.getState().streaming;
timer = window.setTimeout(tick, busy ? 4000 : 20000);
};
schedule();
// Retour sur l'onglet : rafraîchit tout de suite plutôt que d'attendre.
const onVisible = () => {
if (!document.hidden) {
window.clearTimeout(timer);
tick();
}
};
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, [refreshPulse, refreshModels, refreshSessions]);
return (
<div className="flex h-full flex-col bg-base text-ink">
<TopBar />
<div className="flex min-h-0 flex-1">
<Sidebar active={view} onChange={setView} />
{view === "chat" && (
<>
<ChatPanel />
<PreviewPanel />
</>
)}
{view === "history" && <HistoryView onOpen={() => setView("chat")} />}
{view === "tools" && <ToolsView onSettings={() => setView("settings")} />}
{view === "settings" && <SettingsView />}
</div>
</div>
);
}
-795
View File
@@ -1,795 +0,0 @@
/** Petit client API typé pour le backend Loki. */
export interface OllamaStatus {
connected: boolean;
host: string;
version?: string;
error?: string;
default_model: string;
}
export interface OllamaModel {
name: string;
size_go: number;
parameter_size?: string;
quantization?: string;
family?: string;
}
export async function getStatus(): Promise<OllamaStatus> {
const res = await fetch("/api/status");
return res.json();
}
export interface GpuStats {
name: string;
util_pct: number;
vram_used_mb: number;
vram_total_mb: number;
}
export interface SystemStats {
cpu_pct: number;
ram_used_go: number;
ram_total_go: number;
ram_pct: number;
gpu: GpuStats | null;
}
export async function getSystemStats(): Promise<SystemStats> {
const res = await fetch("/api/system/stats");
return res.json();
}
/** Battement groupé : statut + ressources + modèles chargés en UNE requête. */
export interface Pulse {
status: OllamaStatus;
stats: SystemStats;
loaded: LoadedModel[];
}
export async function getPulse(): Promise<Pulse> {
const res = await fetch("/api/system/pulse");
if (!res.ok) throw new Error(`pulse ${res.status}`);
return res.json();
}
// ── Git du workspace ─────────────────────────────────────────────────────
export interface GitCommit {
hash: string;
full_hash: string;
subject: string;
author: string;
when: string;
files_changed: number;
}
const projQuery = (project?: string | null) =>
project ? `&project=${encodeURIComponent(project)}` : "";
export async function getGitLog(project?: string | null): Promise<GitCommit[]> {
try {
const res = await fetch(`/api/git/log?limit=40${projQuery(project)}`);
return (await res.json()).commits;
} catch {
return [];
}
}
export async function getGitDiff(
hash?: string,
project?: string | null
): Promise<string> {
const params = new URLSearchParams();
if (hash) params.set("hash", hash);
if (project) params.set("project", project);
const qs = params.toString();
const res = await fetch(`/api/git/diff${qs ? `?${qs}` : ""}`);
if (!res.ok) return "";
return (await res.json()).diff;
}
export async function revertCommit(
hash: string,
project?: string | null
): Promise<{ message: string }> {
const res = await fetch("/api/git/revert", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hash, project: project ?? null }),
});
if (!res.ok) throw await apiError(res, "revert impossible");
return res.json();
}
export interface HardwareInfo {
loki_gpus: {
index: number;
name: string;
vram_total_mb: number;
vram_used_mb: number;
util_pct: number;
}[];
gpu_override: { name: string; vram_total_mb: number } | null;
ollama: {
host: string;
connected: boolean;
version?: string;
error?: string;
running: {
name: string;
processor: string;
gpu_percent: number;
size_mb: number;
vram_mb: number;
}[];
};
ollama_is_local: boolean;
note: string;
}
export async function getHardware(): Promise<HardwareInfo> {
const res = await fetch("/api/system/hardware");
return res.json();
}
export async function getVersion(): Promise<string> {
try {
const res = await fetch("/api/version");
return (await res.json()).version ?? "?";
} catch {
return "?";
}
}
export interface LoadedModel {
name: string;
on_gpu: boolean;
gpu_percent: number;
}
async function apiError(res: Response, fallback: string): Promise<Error> {
try {
const payload = await res.json();
return new Error(payload?.detail ?? payload?.error ?? fallback);
} catch {
return new Error(fallback);
}
}
interface WarmStatus {
state: "idle" | "loading" | "loaded" | "error";
error?: string;
processor?: "gpu" | "cpu" | "mixte";
gpu_percent?: string;
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Lance le préchargement en arrière-plan puis suit son état. Chaque requête
* reste courte afin qu'un reverse proxy ne puisse plus interrompre le warm-up.
*/
export async function warmModel(
name: string,
keepAlive = "30m"
): Promise<WarmStatus> {
const res = await fetch("/api/models/warm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, keep_alive: keepAlive }),
});
if (!res.ok) {
throw await apiError(res, `préchargement refusé (${res.status})`);
}
// Backoff progressif : réactif au début (modèle déjà chargé), espacé ensuite
// pour ne pas marteler l'API pendant un long chargement.
const deadline = Date.now() + 10 * 60 * 1000;
let delay = 1000;
while (Date.now() < deadline) {
const statusRes = await fetch(
`/api/models/warm/status?name=${encodeURIComponent(name)}`,
{ cache: "no-store" }
);
if (!statusRes.ok) {
throw await apiError(statusRes, `suivi du préchargement refusé (${statusRes.status})`);
}
const status = (await statusRes.json()) as WarmStatus;
if (status.state === "loaded") return status;
if (status.state === "error") {
throw new Error(status.error ?? "préchargement impossible");
}
await wait(delay);
delay = Math.min(delay * 1.5, 5000);
}
throw new Error("préchargement toujours en cours après 10 minutes");
}
export async function getLoadedModels(): Promise<LoadedModel[]> {
try {
const res = await fetch("/api/models/loaded");
return (await res.json()).loaded;
} catch {
return [];
}
}
export async function getModels(): Promise<{
models: OllamaModel[];
default: string;
}> {
const res = await fetch("/api/models");
return res.json();
}
export async function deleteModel(name: string): Promise<void> {
const res = await fetch("/api/models", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.detail ?? `suppression refusée (${res.status})`);
}
}
export interface Session {
id: string;
title: string;
model?: string;
project?: string | null;
created_at: number;
updated_at: number;
message_count?: number;
}
export interface ToolCall {
name: string;
args: Record<string, unknown>;
summary?: string;
status?: "ok" | "error" | "running" | "pending";
}
export interface MessageStats {
eval_count: number; // jetons générés
prompt_eval_count: number; // jetons du prompt
tokens_per_sec: number | null; // vitesse de génération
}
export interface Message {
id: string;
session_id: string;
role: "user" | "assistant";
content: string;
model?: string;
meta?: {
tools?: ToolCall[];
stats?: MessageStats;
thinking?: string;
plan?: string[];
engine?: string;
} | null;
created_at: number;
}
export interface AgentConfig {
system_prompt: string;
temperature: number;
top_p: number;
top_k: number;
max_tokens: number;
num_ctx: number;
num_gpu: number;
num_batch: number;
tools: Record<string, boolean>;
confirm_shell: boolean;
think: boolean;
code_model: string;
plan_mode: boolean;
self_review: boolean;
rag_enabled: boolean;
embed_model: string;
skills_enabled: boolean;
ponytail: boolean;
memory_mode: "off" | "ondemand" | "always";
keep_alive: string;
}
// ── Benchmark de modèles ─────────────────────────────────────────────────
export interface BenchDetail {
task: string;
score: number;
detail: string;
}
export interface BenchResult {
score: number;
details: BenchDetail[];
at: number;
}
export async function getBenchScores(): Promise<Record<string, BenchResult>> {
const res = await fetch("/api/bench");
return (await res.json()).scores;
}
/** Lance le benchmark d'un modèle en streamant la progression. */
export async function runBench(
model: string,
onProgress: (task: string, score: number | null, detail?: string) => void
): Promise<BenchResult | null> {
let res: Response;
try {
res = await fetch("/api/bench", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
} catch (err) {
const raw = err instanceof Error ? err.message : "connexion impossible";
throw new Error(
/network error|failed to fetch|load failed/i.test(raw)
? "connexion au benchmark interrompue par le réseau ou le reverse proxy"
: raw
);
}
if (!res.ok) {
throw await apiError(res, `benchmark refusé (${res.status})`);
}
if (!res.body) throw new Error("le serveur n'a pas renvoyé de progression");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let final: BenchResult | null = null;
const dispatch = (block: string) => {
let event = "message";
const dataLines: string[] = [];
for (const line of block.replace(/\r\n/g, "\n").split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length === 0) return;
const payload = JSON.parse(dataLines.join("\n"));
if (event === "task_start") onProgress(payload.task, null);
else if (event === "task_done")
onProgress(payload.task, payload.score, payload.detail);
else if (event === "error")
throw new Error(payload.message ?? "le benchmark a échoué");
else if (event === "done")
final = { score: payload.score, details: payload.details, at: Date.now() / 1000 };
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const block of events) {
if (block.trim()) dispatch(block);
}
}
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
} catch (err) {
const raw = err instanceof Error ? err.message : "connexion interrompue";
if (/network error|failed to fetch|load failed/i.test(raw)) {
throw new Error("flux du benchmark interrompu par le reverse proxy");
}
throw err;
}
if (!final) throw new Error("le benchmark s'est interrompu avant le résultat");
return final;
}
export async function runShell(
command: string
): Promise<{ command: string; exit_code: number; output: string }> {
const res = await fetch("/api/shell/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command }),
});
return res.json();
}
export async function getConfig(model?: string): Promise<{
config: AgentConfig;
available_tools: string[];
}> {
const query = model ? `?model=${encodeURIComponent(model)}` : "";
const res = await fetch(`/api/config${query}`);
return res.json();
}
// ── Presets de configuration ─────────────────────────────────────────────
export async function listPresets(): Promise<string[]> {
const res = await fetch("/api/config/presets");
return (await res.json()).presets;
}
export async function savePreset(name: string, model?: string): Promise<string[]> {
const query = model ? `?model=${encodeURIComponent(model)}` : "";
const res = await fetch(`/api/config/presets${query}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw await apiError(res, "preset refusé");
return (await res.json()).presets;
}
export async function applyPreset(
name: string,
model?: string
): Promise<AgentConfig> {
const query = model ? `?model=${encodeURIComponent(model)}` : "";
const res = await fetch(`/api/config/presets/apply${query}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw await apiError(res, "preset introuvable");
return (await res.json()).config;
}
export async function deletePreset(name: string): Promise<string[]> {
const res = await fetch(`/api/config/presets?name=${encodeURIComponent(name)}`, {
method: "DELETE",
});
return (await res.json()).presets;
}
export async function saveConfig(
patch: Partial<AgentConfig>,
model?: string
): Promise<AgentConfig> {
const query = model ? `?model=${encodeURIComponent(model)}` : "";
const res = await fetch(`/api/config${query}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return (await res.json()).config;
}
export interface FileNode {
name: string;
path: string;
type: "dir" | "file";
size?: number;
children?: FileNode[];
}
export async function listFiles(project?: string | null): Promise<FileNode[]> {
const query = project ? `?project=${encodeURIComponent(project)}` : "";
const res = await fetch(`/api/files${query}`);
return (await res.json()).tree;
}
export async function fileContent(
path: string,
project?: string | null
): Promise<string> {
const res = await fetch(
`/api/files/content?path=${encodeURIComponent(path)}${projQuery(project)}`
);
if (!res.ok) return "";
return (await res.json()).content;
}
export async function deleteFile(
path: string,
project?: string | null
): Promise<void> {
const res = await fetch(
`/api/files?path=${encodeURIComponent(path)}${projQuery(project)}`,
{ method: "DELETE" }
);
if (!res.ok) {
throw await apiError(res, `suppression impossible (${res.status})`);
}
}
export function downloadFile(path: string, project?: string | null): void {
const link = document.createElement("a");
link.href = `/api/files/download?path=${encodeURIComponent(path)}${projQuery(project)}`;
link.download = path.split(/[\\/]/).pop() ?? "fichier";
document.body.appendChild(link);
link.click();
link.remove();
}
export interface McpServer {
id: string;
label: string;
description: string;
url_param: boolean;
env_params: string[];
enabled: boolean;
params: Record<string, string>;
state: "inactive" | "connected" | "error";
error: string | null;
tools: number;
}
export async function listMcp(): Promise<McpServer[]> {
const res = await fetch("/api/mcp");
return (await res.json()).servers;
}
export async function updateMcp(
id: string,
enabled: boolean,
params: Record<string, string>
): Promise<McpServer[]> {
const res = await fetch(`/api/mcp/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, params }),
});
if (!res.ok) throw await apiError(res, "mise à jour MCP impossible");
return (await res.json()).servers;
}
export async function testMcp(
id: string
): Promise<{ ok: boolean; tools: string[]; error: string | null }> {
const res = await fetch(`/api/mcp/${id}/test`, { method: "POST" });
return res.json();
}
export async function listSessions(): Promise<Session[]> {
const res = await fetch("/api/sessions");
return (await res.json()).sessions;
}
export async function createSession(
model?: string,
project?: string | null
): Promise<Session> {
const res = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Nouvelle session",
model,
project: project ?? null,
}),
});
return res.json();
}
export async function listProjects(): Promise<{
projects: { name: string; files: number }[];
root_files: number;
}> {
const res = await fetch("/api/projects");
return res.json();
}
export async function createProject(name: string): Promise<void> {
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw await apiError(res, "création du projet impossible");
}
export async function setSessionProject(
id: string,
project: string | null
): Promise<void> {
const res = await fetch(`/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project: project ?? "" }),
});
if (!res.ok) throw await apiError(res, "changement de projet impossible");
}
export async function getSession(
id: string
): Promise<{ session: Session; messages: Message[] }> {
const res = await fetch(`/api/sessions/${id}`);
return res.json();
}
export async function deleteSession(id: string): Promise<void> {
await fetch(`/api/sessions/${id}`, { method: "DELETE" });
}
export async function renameSession(id: string, title: string): Promise<void> {
const res = await fetch(`/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!res.ok) {
throw await apiError(res, `renommage impossible (${res.status})`);
}
}
/** Envoie un message et streame la réponse de l'agent via SSE. */
export async function streamChat(
body: { session_id: string; content: string; model?: string; mode?: string },
handlers: {
onToken: (t: string) => void;
onThinking: (t: string) => void;
onToolCall: (call: ToolCall) => void;
onToolResult: (call: ToolCall) => void;
onToolConfirm: (command: string) => void;
onStatus: (msg: string) => void;
onNotice: (msg: string) => void;
onPlan?: (steps: string[]) => void;
onPlanStep?: (index: number) => void;
onRevision?: (content: string) => void;
onDone: (full: string) => void;
onError: (msg: string) => void;
onAbort?: () => void;
},
signal?: AbortSignal
): Promise<void> {
let res: Response;
try {
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
handlers.onAbort?.();
return;
}
const raw = err instanceof Error ? err.message : "serveur Loki injoignable";
handlers.onError(
/network error|failed to fetch/i.test(raw)
? "Serveur Loki injoignable. Vérifiez le reverse proxy et le conteneur."
: raw
);
return;
}
if (!res.ok) {
let message = `requête refusée (${res.status})`;
try {
const payload = await res.json();
message = payload.detail ?? payload.error ?? message;
} catch {
/* réponse non JSON */
}
handlers.onError(message);
return;
}
if (!res.body) {
handlers.onError("pas de flux de réponse");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let terminal = false;
let failed = false;
const dispatch = (raw: string) => {
const block = raw.replace(/\r\n/g, "\n");
let event = "message";
const dataLines: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length === 0) return;
try {
const payload = JSON.parse(dataLines.join("\n"));
if (event === "token") handlers.onToken(payload.content);
else if (event === "plan") handlers.onPlan?.(payload.steps);
else if (event === "plan_step") handlers.onPlanStep?.(payload.index);
else if (event === "revision") handlers.onRevision?.(payload.content);
else if (event === "thinking") handlers.onThinking(payload.content);
else if (event === "status") handlers.onStatus(payload.message);
else if (event === "notice") handlers.onNotice(payload.message);
else if (event === "tool_call")
handlers.onToolCall({ ...payload, status: "running" });
else if (event === "tool_result") handlers.onToolResult(payload);
else if (event === "tool_confirm") handlers.onToolConfirm(payload.command);
else if (event === "error") {
failed = true;
handlers.onError(payload.message);
} else if (event === "done") {
terminal = true;
if (payload.error) {
if (!failed) handlers.onError(payload.error);
failed = true;
} else if (!failed) {
handlers.onDone(payload.content);
}
}
} catch {
if (!failed) {
failed = true;
handlers.onError("réponse illisible reçue du serveur");
}
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const block of events) {
if (block.trim()) dispatch(block);
}
}
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
} catch (err) {
if (!failed) {
if (err instanceof DOMException && err.name === "AbortError") {
handlers.onAbort?.();
return;
}
failed = true;
const raw = err instanceof Error ? err.message : "connexion interrompue";
handlers.onError(
/network error|failed to fetch/i.test(raw)
? "Connexion interrompue pendant le chargement du modèle. Vérifiez OLLAMA_HOST et le délai du reverse proxy."
: raw
);
}
}
if (!terminal && !failed) {
handlers.onError("le serveur a fermé la réponse avant sa fin");
}
}
/** Télécharge un modèle en streamant la progression via SSE. */
export async function pullModel(
name: string,
onProgress: (status: string, percent: number) => void
): Promise<void> {
const res = await fetch("/api/models/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const data = line.replace(/^data: /, "").trim();
if (!data || data === "[DONE]") continue;
try {
const chunk = JSON.parse(data);
const percent =
chunk.total && chunk.completed
? Math.round((chunk.completed / chunk.total) * 100)
: 0;
onProgress(chunk.status ?? "", percent);
} catch {
/* ligne partielle, ignorée */
}
}
}
}
-63
View File
@@ -1,63 +0,0 @@
import {
ChatIcon,
ClockIcon,
FilesIcon,
NodesIcon,
SettingsIcon,
} from "./Icon";
export type View = "chat" | "history" | "files" | "tools" | "settings";
const items: { id: View; label: string; icon: React.ReactNode }[] = [
{ id: "chat", label: "Chat", icon: <ChatIcon /> },
{ id: "history", label: "Historique", icon: <ClockIcon /> },
{ id: "files", label: "Fichiers", icon: <FilesIcon /> },
{ id: "tools", label: "Outils", icon: <NodesIcon /> },
];
const btn = (on: boolean) =>
`flex h-10 w-10 items-center justify-center border ${
on
? "border-white bg-accent text-white shadow-accent-soft"
: "border-chrome-3 text-on-dark-2 hover:text-ink"
}`;
/** Barre d'activité verticale (60px) sombre à gauche. */
export function ActivityBar({
active,
onChange,
}: {
active: View;
onChange: (v: View) => void;
}) {
return (
<div className="flex w-[60px] flex-none flex-col items-center gap-2 border-r border-line bg-bar py-3">
{items.map((it) => (
<button
key={it.id}
onClick={() => onChange(it.id)}
className={btn(active === it.id)}
title={it.label}
aria-label={it.label}
>
{it.icon}
</button>
))}
<div className="flex-1" />
<button
onClick={() => onChange("settings")}
className={btn(active === "settings")}
title="Configuration"
aria-label="Configuration"
>
<SettingsIcon />
</button>
<div className="font-pixel flex h-[34px] w-[34px] items-center justify-center border border-line bg-line-soft text-[11px] text-ink-3">
M
</div>
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
import { useStore } from "../store/useStore";
import { downloadFile, type FileNode } from "../api/client";
import { DownloadIcon, TrashIcon } from "./Icon";
/** Arborescence du workspace : ouvrir dans l'aperçu, télécharger, supprimer. */
export function FileTree({
nodes,
depth = 0,
}: {
nodes: FileNode[];
depth?: number;
}) {
const { openPreview, previewPath, removeFile, currentProject } = useStore();
const confirmDelete = (n: FileNode) => {
const msg =
n.type === "dir"
? `Supprimer le dossier ${n.path} et tout son contenu ?`
: `Supprimer ${n.path} ?`;
if (window.confirm(msg)) void removeFile(n.path);
};
return (
<>
{nodes.map((n) => {
const active = n.type === "file" && n.path === previewPath;
return (
<div key={n.path}>
<div
onClick={() => n.type === "file" && openPreview(n.path)}
className={`group mb-[2px] flex items-center gap-2 rounded-card px-2 py-[5px] text-[13.5px] ${
n.type === "file"
? active
? "cursor-pointer border border-accent-line bg-accent-ghost text-ink"
: "cursor-pointer border border-transparent text-ink-3 hover:bg-accent-ghost"
: "text-muted-2"
}`}
style={{ paddingLeft: 8 + depth * 14 }}
>
{n.type === "dir" ? (
<span className="text-muted-3">▾</span>
) : (
<span
className={`h-1.5 w-1.5 rounded-sm ${
active ? "bg-accent" : "bg-muted-3"
}`}
/>
)}
<span className="min-w-0 flex-1 truncate">{n.name}</span>
{n.type === "file" && (
<button
onClick={(event) => {
event.stopPropagation();
downloadFile(n.path, currentProject());
}}
className="hidden flex-none text-muted-3 hover:text-accent group-hover:block"
title={`Télécharger ${n.name}`}
>
<DownloadIcon size={13} />
</button>
)}
<button
onClick={(event) => {
event.stopPropagation();
confirmDelete(n);
}}
className="hidden flex-none text-muted-3 hover:text-warn group-hover:block"
title={`Supprimer ${n.name}`}
>
<TrashIcon size={13} />
</button>
</div>
{n.children && <FileTree nodes={n.children} depth={depth + 1} />}
</div>
);
})}
</>
);
}
-126
View File
@@ -1,126 +0,0 @@
/** Icônes SVG (style line, stroke) reprises du visuel. */
type Props = { size?: number; className?: string };
const base = (size: number, className?: string) => ({
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
className,
});
export const ChatIcon = ({ size = 19, className }: Props) => (
<svg {...base(size, className)}>
<path d="M4 6h16v10H10l-4 3v-3H4z" />
</svg>
);
export const ClockIcon = ({ size = 19, className }: Props) => (
<svg {...base(size, className)}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 1.8" />
</svg>
);
export const FilesIcon = ({ size = 19, className }: Props) => (
<svg {...base(size, className)}>
<path d="M4 7h6l2 2h8v9H4z" />
</svg>
);
export const NodesIcon = ({ size = 19, className }: Props) => (
<svg {...base(size, className)}>
<circle cx="7" cy="7" r="2.4" />
<circle cx="17" cy="7" r="2.4" />
<circle cx="7" cy="17" r="2.4" />
<circle cx="17" cy="17" r="2.4" />
</svg>
);
export const SettingsIcon = ({ size = 18, className }: Props) => (
<svg {...base(size, className)}>
<line x1="4" y1="8" x2="20" y2="8" />
<line x1="4" y1="13" x2="20" y2="13" />
<line x1="4" y1="18" x2="20" y2="18" />
</svg>
);
export const ChevronDown = ({ size = 13, className }: Props) => (
<svg {...base(size, className)} strokeWidth={2}>
<path d="m6 9 6 6 6-6" />
</svg>
);
export const PlusIcon = ({ size = 13, className }: Props) => (
<svg {...base(size, className)} strokeWidth={2.2}>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const RefreshIcon = ({ size = 15, className }: Props) => (
<svg {...base(size, className)}>
<path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" />
</svg>
);
export const SendIcon = ({ size = 15, className }: Props) => (
<svg {...base(size, className)} strokeWidth={2.2}>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
);
export const ClipIcon = ({ size = 16, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.8}>
<path d="M21 11.5 12 20a5 5 0 0 1-7-7l8.5-8.5a3.5 3.5 0 0 1 5 5L10 16" />
</svg>
);
export const DownloadIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.9}>
<path d="M12 4v11m0 0 4-4m-4 4-4-4M5 20h14" />
</svg>
);
export const TrashIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.9}>
<path d="M4 7h16M10 7V5h4v2M9 7l.8 13h4.4L15 7M6.5 7l.7 13h9.6l.7-13" />
</svg>
);
export const CopyIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.7}>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h8" />
</svg>
);
export const CheckIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={2.3}>
<path d="m5 12 4.5 4.5L19 7" />
</svg>
);
/** Logo Loki : carré teinté à filet accent + petit carré accent (maquette). */
export const LokiMark = ({ size = 30 }: { size?: number }) => (
<div
className="grid place-items-center border border-accent bg-accent-ghost"
style={{
width: size,
height: size,
borderRadius: Math.round(size * 0.27),
}}
>
<div
className="bg-accent"
style={{
width: size * 0.3,
height: size * 0.3,
borderRadius: 2,
}}
/>
</div>
);
-151
View File
@@ -1,151 +0,0 @@
import { useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CheckIcon, CopyIcon } from "./Icon";
function copyWithTextarea(value: string) {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
textarea.remove();
if (!ok) throw new Error("copy failed");
}
function CodeBlock({ lang, code }: { lang: string; code: string }) {
const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const copy = async () => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(code);
} else {
copyWithTextarea(code);
}
setCopied(true);
setFailed(false);
setTimeout(() => setCopied(false), 1500);
} catch {
try {
copyWithTextarea(code);
setCopied(true);
setFailed(false);
setTimeout(() => setCopied(false), 1500);
} catch {
setFailed(true);
setTimeout(() => setFailed(false), 1800);
}
}
};
return (
<div className="my-2 overflow-hidden border border-line bg-card-deep shadow-hard-sm" style={{ borderRadius: 7 }}>
<div className="flex items-center justify-between border-b border-chrome-2 px-3 py-1.5">
<span className="font-mono text-[10.5px] uppercase tracking-wide text-on-dark-2">
{lang || "code"}
</span>
<button
type="button"
onClick={copy}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-on-dark-2 transition-colors hover:text-ink"
>
{copied ? <CheckIcon size={13} /> : <CopyIcon size={13} />}
{failed ? "Erreur" : copied ? "Copié" : "Copier"}
</button>
</div>
<pre className="scr m-0 overflow-auto p-3 font-mono text-[12px] leading-relaxed text-on-dark">
<code>{code}</code>
</pre>
</div>
);
}
const components: Components = {
pre: ({ children }) => <>{children}</>,
code({ className, children }) {
const text = String(children).replace(/\n$/, "");
const match = /language-(\w+)/.exec(className || "");
if (match || text.includes("\n")) {
return <CodeBlock lang={match?.[1] ?? ""} code={text} />;
}
return (
<code className="rounded bg-sunken px-1 py-0.5 font-mono text-[12px] text-accent-1">
{children}
</code>
);
},
h1: ({ children }) => (
<h1 className="mb-2 mt-3 text-[17px] font-bold text-ink first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-3 text-[15px] font-bold text-ink first:mt-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="mb-1.5 mt-2.5 text-[13.5px] font-bold text-ink first:mt-0">
{children}
</h3>
),
p: ({ children }) => <p className="my-2 first:mt-0 last:mb-0">{children}</p>,
ul: ({ children }) => (
<ul className="my-2 list-disc space-y-1 pl-5 marker:text-muted-3">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="my-2 list-decimal space-y-1 pl-5 marker:text-muted-3">
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-ink">{children}</strong>
),
em: ({ children }) => <em className="italic">{children}</em>,
blockquote: ({ children }) => (
<blockquote className="my-2 border-l border-line-strong pl-3 text-muted">
{children}
</blockquote>
),
hr: () => <hr className="my-3 border-line-soft" />,
table: ({ children }) => (
<div className="my-2 overflow-x-auto">
<table className="w-full border-collapse text-[12.5px]">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-line-strong bg-card-deep px-2.5 py-1.5 text-left font-semibold text-ink-2">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-line-soft px-2.5 py-1.5">{children}</td>
),
};
export function MessageContent({ text }: { text: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
);
}
-124
View File
@@ -1,124 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ChevronDown } from "./Icon";
/** Sélecteur de modèle Ollama (chip orange, pastille = état de chargement).
variant "composer" : taille alignée sur les chips du composer, menu vers
le haut. */
export function ModelSelector({
variant = "top",
}: {
variant?: "top" | "composer";
}) {
const {
models,
selectedModel,
setSelectedModel,
loadedModels,
warmingModel,
warmError,
} = useStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const loadedInfo = (name: string) =>
loadedModels.find((m) => m.name === name);
const selectedLoaded = loadedInfo(selectedModel);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={
variant === "composer"
? "flex h-8 min-w-0 max-w-[220px] items-center gap-1.5 border border-line bg-accent px-2.5 text-[13px] text-white"
: "flex h-[34px] min-w-0 max-w-[260px] items-center gap-2 border border-white bg-accent px-3 text-white shadow-accent-soft"
}
title={selectedModel || undefined}
style={{ borderRadius: 7 }}
>
<span
className={`h-2.5 w-2.5 border border-line ${
warmingModel === selectedModel
? "bg-accent animate-pulse"
: selectedLoaded
? selectedLoaded.on_gpu
? "bg-ok"
: "bg-warn"
: "bg-white"
}`}
title={
warmError
? warmError
: warmingModel === selectedModel
? "préchargement en cours…"
: selectedLoaded
? selectedLoaded.on_gpu
? "chargé sur GPU"
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
: "à charger"
}
/>
<span className="min-w-0 truncate text-[14px] leading-none">{selectedModel || "—"}</span>
<ChevronDown size={12} className="flex-none" />
</button>
{open && (
<div
className={`absolute z-20 w-64 border border-line bg-card p-1 shadow-hard ${
variant === "composer"
? "bottom-[calc(100%+6px)] left-0"
: "right-0 top-11"
}`}
>
{models.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-2">
Aucun modèle installé
</div>
)}
{models.map((m) => (
<button
key={m.name}
onClick={() => {
setSelectedModel(m.name);
setOpen(false);
}}
className={`flex w-full items-center gap-2 px-2 py-2 text-left hover:bg-base ${
m.name === selectedModel ? "bg-base" : ""
}`}
>
<span
className={`h-2 w-2 border border-line ${
m.name === selectedModel ? "bg-accent" : "bg-card"
}`}
/>
<span className="min-w-0 flex-1 truncate text-xs text-ink-2" title={m.name}>
{m.name}
</span>
{loadedInfo(m.name) && (
<span
className={`h-1.5 w-1.5 border border-line ${
loadedInfo(m.name)!.on_gpu ? "bg-ok" : "bg-warn"
}`}
title={loadedInfo(m.name)!.on_gpu ? "chargé GPU" : "chargé CPU"}
/>
)}
{m.size_go > 0 && (
<span className="text-[10px] text-muted-2">{m.size_go} Go</span>
)}
</button>
))}
</div>
)}
</div>
);
}
-118
View File
@@ -1,118 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { createProject } from "../api/client";
/** Sélecteur de projet du composer : 📁 <projet> + menu (liste, création). */
export function ProjectChip() {
const { projects, refreshProjects, currentProject, setProject } = useStore();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const active = currentProject();
useEffect(() => {
if (open) void refreshProjects();
}, [open, refreshProjects]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
setError(null);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const choose = async (name: string | null) => {
setOpen(false);
await setProject(name);
};
const create = async () => {
const name = draft.trim();
if (!name) return;
try {
await createProject(name);
setCreating(false);
setDraft("");
await choose(name);
} catch (err) {
setError(err instanceof Error ? err.message : "création impossible");
}
};
return (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex h-8 items-center gap-1.5 border border-line bg-base px-2.5 text-[12px] text-ink-2"
title="Projet de travail de cette session"
>
📁 <span className="max-w-[140px] truncate">{active ?? "workspace"}</span>
</button>
{open && (
<div
className="absolute bottom-[calc(100%+6px)] left-0 z-30 w-[240px] border border-line bg-card shadow-hard"
style={{ borderRadius: 7 }}
>
<button
onClick={() => choose(null)}
className={`block w-full px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === null ? "bg-base font-bold" : ""
}`}
>
workspace (racine)
</button>
{projects.map((p) => (
<button
key={p.name}
onClick={() => choose(p.name)}
className={`block w-full border-t border-line-soft px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === p.name ? "bg-base font-bold" : ""
}`}
>
📁 {p.name}
<span className="ml-1.5 text-[11px] text-muted-2">
{p.files} fichier{p.files > 1 ? "s" : ""}
</span>
</button>
))}
<div className="border-t border-line-soft p-2">
{creating ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void create();
if (e.key === "Escape") {
setCreating(false);
setError(null);
}
}}
placeholder="nom-du-projet"
className="w-full border border-line bg-base px-2 py-1 text-[12px] text-ink outline-none"
/>
) : (
<button
onClick={() => setCreating(true)}
className="w-full text-left text-[13px] font-bold text-accent"
>
+ Nouveau projet
</button>
)}
{error && <div className="mt-1 text-[11px] text-warn">{error}</div>}
</div>
</div>
)}
</div>
);
}
-174
View File
@@ -1,174 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ChevronDown, PlusIcon, TrashIcon } from "./Icon";
import { relTime } from "../lib/time";
/** Sélecteur de session de la barre supérieure : titre courant + menu déroulant
* (nouvelle session, liste, renommage inline, suppression). */
export function SessionMenu() {
const {
sessions,
currentSessionId,
streamingSessionId,
newSession,
openSession,
removeSession,
renameSession,
} = useStore();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const current = sessions.find((s) => s.id === currentSessionId);
// Fermeture au clic extérieur + Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setEditingId(null);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setEditingId(null);
}
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const commitRename = async (id: string) => {
const title = draft.trim();
setEditingId(null);
if (title) await renameSession(id, title);
};
return (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex cursor-pointer items-center gap-2 text-[13px] text-on-dark hover:text-ink"
title="Sessions"
>
<span className="max-w-[220px] truncate">
{current?.title ?? "Nouvelle session"}
</span>
<ChevronDown className="text-on-dark-3" />
</button>
{open && (
<div
className="absolute left-0 top-[calc(100%+9px)] z-30 w-[300px] border border-line bg-chrome-2 shadow-hard"
style={{ borderRadius: 7 }}
>
<button
onClick={async () => {
setOpen(false);
await newSession();
}}
className="flex w-full cursor-pointer items-center gap-2 border-b border-chrome-3 px-3 py-2.5 text-[13px] font-bold text-accent hover:bg-chrome-3"
>
<PlusIcon />
Nouvelle session
</button>
<div className="scr max-h-[320px] overflow-auto">
{sessions.length === 0 && (
<div className="px-3 py-4 text-center text-[12px] text-on-dark-3">
Aucune session enregistrée.
</div>
)}
{sessions.map((s) => {
const active = s.id === currentSessionId;
const working = s.id === streamingSessionId;
return (
<div
key={s.id}
onClick={() => {
if (editingId === s.id) return;
setOpen(false);
void openSession(s.id);
}}
className={`group cursor-pointer border-b border-chrome-3 px-3 py-2 last:border-b-0 ${
active ? "bg-chrome-3" : "hover:bg-chrome-3"
}`}
>
<div className="flex items-center gap-1.5">
{editingId === s.id ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(s.id);
if (e.key === "Escape") {
e.stopPropagation();
setEditingId(null);
}
}}
onBlur={() => setEditingId(null)}
className="min-w-0 flex-1 border border-line bg-card-deep px-1.5 py-0.5 text-[13px] text-ink outline-none"
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-[13px] ${
active ? "text-ink" : "text-ink-3"
}`}
>
{s.title}
</span>
)}
{working && (
<span
className="flex-none border border-line bg-accent px-1 py-0.5 text-[9px] leading-none text-white"
title="Session en cours de travail"
>
EN COURS
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
setEditingId(s.id);
setDraft(s.title);
}}
className="hidden flex-none text-[11px] text-on-dark-3 hover:text-ink group-hover:block"
title="Renommer la session"
>
✎
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (window.confirm("Supprimer cette session ?")) {
void removeSession(s.id);
}
}}
className="hidden flex-none text-on-dark-3 hover:text-accent group-hover:block"
title="Supprimer la session"
>
<TrashIcon size={12} />
</button>
</div>
<div className="mt-0.5 text-[11px] text-on-dark-3">
{relTime(s.updated_at)} · {s.message_count ?? 0} message
{(s.message_count ?? 0) > 1 ? "s" : ""}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
-211
View File
@@ -1,211 +0,0 @@
import { useStore } from "../store/useStore";
import {
ChatIcon,
ClockIcon,
LokiMark,
NodesIcon,
PlusIcon,
SettingsIcon,
} from "./Icon";
export type View = "chat" | "history" | "tools" | "settings";
// Les fichiers ne sont plus une vue : ils vivent dans l'onglet « Fichiers »
// du panneau droit, à côté de Code.
const NAV: { id: View; label: string; icon: React.ReactNode }[] = [
{ id: "chat", label: "Discussion", icon: <ChatIcon /> },
{ id: "history", label: "Historique", icon: <ClockIcon /> },
{ id: "tools", label: "Outils", icon: <NodesIcon /> },
];
/** Nombre de discussions récentes listées sous « Discussion ». */
const RECENT = 5;
/** Barre latérale (264 px) : marque, navigation, état Ollama, compte. */
export function Sidebar({
active,
onChange,
}: {
active: View;
onChange: (v: View) => void;
}) {
const {
status,
selectedModel,
systemStats,
sessions,
currentSessionId,
streamingSessionId,
newSession,
openSession,
} = useStore();
const gpu = systemStats?.gpu ?? null;
const recent = sessions.slice(0, RECENT);
const startChat = () => {
void newSession();
onChange("chat");
};
const pick = (id: string) => {
void openSession(id);
onChange("chat");
};
return (
<aside className="flex w-[264px] flex-none flex-col border-r border-line bg-panel px-3.5 py-4">
{/* Marque */}
<div className="flex items-center gap-2.5 px-1.5 pb-4">
<LokiMark size={26} />
<span className="text-[16px] font-medium tracking-tight text-ink">Loki</span>
<span className="ml-auto rounded-full border border-line px-2 py-0.5 text-[11px] text-muted-3">
local
</span>
</div>
<div className="mx-1.5 mb-3.5 h-px bg-line" />
<nav className="flex flex-col gap-[3px]">
<NavItem
id="chat"
label={NAV[0].label}
icon={NAV[0].icon}
active={active === "chat"}
onClick={() => onChange("chat")}
/>
{/* Discussions récentes, directement sous « Discussion ». */}
<div className="mb-1 ml-2 flex flex-col gap-px border-l border-line pl-2">
<button
onClick={startChat}
className="flex items-center gap-1.5 rounded-card px-2 py-1.5 text-left text-[13px] text-accent hover:bg-accent-ghost"
>
<PlusIcon />
Nouvelle discussion
</button>
{recent.map((s) => (
<button
key={s.id}
onClick={() => pick(s.id)}
title={s.title}
className={`flex items-center gap-1.5 truncate rounded-card px-2 py-1.5 text-left text-[13px] ${
s.id === currentSessionId
? "bg-accent-ghost text-ink"
: "text-muted hover:bg-accent-ghost hover:text-ink"
}`}
>
<span className="min-w-0 flex-1 truncate">{s.title}</span>
{s.id === streamingSessionId && (
<span className="h-1.5 w-1.5 flex-none rounded-full bg-accent" />
)}
</button>
))}
{recent.length === 0 && (
<span className="px-2 py-1.5 text-[12.5px] text-muted-3">
Aucune discussion
</span>
)}
</div>
{NAV.slice(1).map((it) => (
<NavItem
key={it.id}
{...it}
active={active === it.id}
onClick={() => onChange(it.id)}
/>
))}
</nav>
<div className="font-pixel px-2.5 pb-2 pt-5 text-[10px] text-label">
Configuration
</div>
<NavItem
id="settings"
label="Modèle & génération"
icon={<SettingsIcon />}
active={active === "settings"}
onClick={() => onChange("settings")}
/>
<div className="flex-1" />
{/* État Ollama + GPU */}
<div className="rounded-card border border-line p-3">
<div className="mb-2 flex items-center gap-2">
<span
className={`h-1.5 w-1.5 rounded-full ${
status?.connected ? "bg-accent" : "bg-warn"
}`}
/>
<span className="text-[12.5px] text-ink-3">
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"}
</span>
</div>
<div className="mb-2.5 truncate font-mono text-[12px] text-muted-3">
{selectedModel || "aucun modèle"}
</div>
{gpu && (
<>
<div className="h-1 overflow-hidden rounded-sm bg-line-soft">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(
100,
(gpu.vram_used_mb / Math.max(gpu.vram_total_mb, 1)) * 100
)}%`,
}}
/>
</div>
<div className="mt-2 text-[11.5px] text-muted-3">
VRAM {(gpu.vram_used_mb / 1000).toFixed(1)} /{" "}
{(gpu.vram_total_mb / 1000).toFixed(1)} Go · GPU{" "}
{Math.round(gpu.util_pct)} %
</div>
</>
)}
</div>
{/* Compte local */}
<div className="mt-3 flex items-center gap-2.5 rounded-card border border-line px-3 py-2.5">
<span className="grid h-[30px] w-[30px] flex-none place-items-center rounded-full bg-line-soft text-[12px] text-ink-3">
M
</span>
<span className="min-w-0">
<span className="block text-[13.5px] text-ink">Compte local</span>
<span className="block truncate font-mono text-[11.5px] text-muted-3">
{status?.host || "workspace"}
</span>
</span>
</div>
</aside>
);
}
function NavItem({
label,
icon,
active,
onClick,
}: {
id: View;
label: string;
icon: React.ReactNode;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex w-full items-center gap-2.5 rounded-card border px-2.5 py-2 text-left text-[14px] ${
active
? "border-accent-line bg-accent-ghost text-ink"
: "border-transparent text-ink-3 hover:bg-accent-ghost hover:text-ink"
}`}
>
{icon}
{label}
</button>
);
}
-114
View File
@@ -1,114 +0,0 @@
import type { ToolCall } from "../api/client";
/** Carte d'appel d'outil rendue dans le fil (fidèle à la maquette). */
export function ToolCard({ call }: { call: ToolCall }) {
const running = call.status === "running";
const error = call.status === "error";
const pending = call.status === "pending";
// Argument principal affiché entre parenthèses selon l'outil.
const mainArg =
(call.args?.path as string) ??
(call.args?.query as string) ??
(call.args?.pattern as string) ??
(call.args?.command as string) ??
(call.args?.instruction as string);
const argPreview =
typeof mainArg === "string"
? `("${mainArg.length > 48 ? mainArg.slice(0, 48) + "…" : mainArg}")`
: Object.keys(call.args ?? {}).length
? "(…)"
: "()";
const write = call.name === "write_file" || call.name === "edit_file";
return (
<div className="mb-[11px] overflow-hidden border border-line bg-card shadow-hard-sm" style={{ borderRadius: 7 }}>
<div className="flex items-center gap-2.5 px-3 py-2.5">
<span
className={`flex h-[26px] w-[26px] items-center justify-center border border-line ${
write ? "bg-accent text-white" : "bg-card-deep text-ink"
}`}
>
<ToolGlyph name={call.name} />
</span>
<span className="text-[14px] text-ink">{call.name}</span>
<span className="text-[13px] text-muted-3">{argPreview}</span>
<div className="flex-1" />
{running ? (
<span className="flex items-center gap-1.5 text-[13px] text-muted-2">
<span className="h-2.5 w-2.5 animate-spin rounded-full border border-muted-3 border-t-accent" />
en cours
</span>
) : pending ? (
<span className="text-[13px] text-accent">⏸ à valider</span>
) : (
<span
className={`flex items-center gap-1.5 text-[13px] ${
error ? "text-warn" : "text-ok"
}`}
>
{error ? "✕ échec" : "✓ terminé"}
</span>
)}
</div>
{call.summary && !running && (
<div className="border-t border-line-soft px-3 py-2 text-[13px] text-muted-2">
→ {call.summary}
</div>
)}
</div>
);
}
function ToolGlyph({ name }: { name: string }) {
const common = {
width: 13,
height: 13,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.9,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
if (name === "write_file" || name === "edit_file")
return (
<svg {...common}>
<path d="M5 19h14M7 14l9-9 3 3-9 9-4 1z" />
</svg>
);
if (name === "list_dir")
return (
<svg {...common}>
<path d="M4 7h6l2 2h8v9H4z" />
</svg>
);
if (name === "code_task")
return (
<svg {...common}>
<path d="m8 6-5 6 5 6M16 6l5 6-5 6" />
</svg>
);
if (name === "web_search" || name === "grep_search")
return (
<svg {...common}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
);
if (name === "run_shell")
return (
<svg {...common}>
<path d="m6 9 3 3-3 3M13 15h5" />
<rect x="2" y="4" width="20" height="16" rx="2" />
</svg>
);
// read_file (défaut)
return (
<svg {...common}>
<path d="M7 4h7l4 4v12H7z" />
<path d="M14 4v4h4" />
</svg>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { useEffect, useState } from "react";
import { useStore } from "../store/useStore";
import { getVersion } from "../api/client";
import { SessionMenu } from "./SessionMenu";
/** Barre supérieure : conversation courante, ressources, statut Ollama. */
export function TopBar() {
const status = useStore((s) => s.status);
const stats = useStore((s) => s.systemStats);
const connected = status?.connected ?? false;
const [version, setVersion] = useState("");
useEffect(() => {
getVersion().then(setVersion);
}, []);
return (
<div className="flex h-[54px] flex-none items-center gap-3.5 border-b border-line bg-bar px-4">
{/* La marque vit désormais dans la barre latérale : on ne garde ici que
le contexte de la conversation en cours. */}
<SessionMenu />
{version && version !== "dev" && (
<span
className="text-[12px] text-muted-3"
title={`build ${version}`}
>
{version.slice(0, 7)}
</span>
)}
<div className="flex-1" />
{/* Stats système temps réel : CPU, RAM, GPU/VRAM */}
{stats && (
<div className="flex h-8 items-center gap-2.5 border border-chrome-3 bg-chrome-2 px-[11px] text-[13px] text-on-dark">
<span>CPU {stats.cpu_pct.toFixed(0)}%</span>
<span className="text-on-dark-3">·</span>
<span>RAM {stats.ram_pct.toFixed(0)}%</span>
{stats.gpu && (
<>
<span className="text-on-dark-3">·</span>
<span>GPU {stats.gpu.util_pct.toFixed(0)}%</span>
<span className="text-on-dark-3">
VRAM {(stats.gpu.vram_used_mb / 1024).toFixed(1)}/
{(stats.gpu.vram_total_mb / 1024).toFixed(1)}G
</span>
</>
)}
</div>
)}
{/* Statut Ollama */}
<div
className="flex h-8 items-center gap-2 border border-chrome-3 bg-chrome-2 px-[11px]"
title={connected ? status?.host : status?.error}
>
<span
className={`h-[11px] w-[11px] border border-line ${
connected ? "bg-ok" : "bg-warn"
}`}
/>
<span className="text-[13px] text-on-dark">Ollama</span>
<span className="text-[13px] text-on-dark-3">
{connected ? status?.host.replace(/^https?:\/\//, "") : "déconnecté"}
</span>
</div>
</div>
);
}
-56
View File
@@ -1,56 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
background: #f7f8fa;
color: #1c2536;
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Titres : léger resserrement, comme dans la maquette Nocturne. */
h1,
h2,
h3 {
letter-spacing: -0.025em;
}
/* Ex-police pixel : « kicker » de la maquette (petites capitales espacées). */
.font-pixel {
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* Barres de défilement discrètes (règles .scr de la maquette) */
.scr::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scr::-webkit-scrollbar-thumb {
background: #c3c9d2;
border-radius: 6px;
border: 3px solid transparent;
background-clip: content-box;
}
.scr::-webkit-scrollbar-track {
background: transparent;
}
/* Focus visible accentué, sélection teintée — repris de la maquette. */
:focus-visible {
outline: 2px solid #2f4a7a;
outline-offset: 2px;
}
::selection {
background: rgba(47, 74, 122, 0.32);
}
-8
View File
@@ -1,8 +0,0 @@
/** Date relative courte en français (« il y a 5 min »). */
export function relTime(ts: number): string {
const diff = Date.now() / 1000 - ts;
if (diff < 60) return "à l'instant";
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`;
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`;
return `il y a ${Math.floor(diff / 86400)} j`;
}
-10
View File
@@ -1,10 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
-94
View File
@@ -1,94 +0,0 @@
import { useEffect } from "react";
import { useStore } from "../store/useStore";
export function HistoryView({ onOpen }: { onOpen: () => void }) {
const { sessions, refreshSessions, openSession, removeSession } = useStore();
useEffect(() => {
refreshSessions();
}, [refreshSessions]);
return (
<Page title="Historique" subtitle="Toutes les conversations enregistrées localement.">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3">
{sessions.map((session) => (
<div key={session.id} className="border border-line bg-card p-4 shadow-hard">
<div className="truncate text-[15px] font-semibold text-ink">{session.title}</div>
<div className="mt-1 text-[12px] text-muted-2">
{session.message_count ?? 0} message(s) · {new Date(session.updated_at * 1000).toLocaleString("fr-FR")}
</div>
<div className="mt-4 flex gap-2">
<button
onClick={async () => {
await openSession(session.id);
onOpen();
}}
className="border border-line bg-accent px-3 py-1.5 text-[12px] font-bold text-white"
>
Ouvrir
</button>
<button
onClick={() => removeSession(session.id)}
className="border border-line bg-base px-3 py-1.5 text-[12px] text-warn"
>
Supprimer
</button>
</div>
</div>
))}
{sessions.length === 0 && <Empty text="Aucune conversation enregistrée." />}
</div>
</Page>
);
}
export function ToolsView({ onSettings }: { onSettings: () => void }) {
const { config, availableTools, refreshConfig, selectedModel } = useStore();
useEffect(() => {
refreshConfig();
}, [refreshConfig, selectedModel]);
return (
<Page title="Outils" subtitle={`Capacités proposées à ${selectedModel || "l’agent"}.`}>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{availableTools.map((name) => {
const enabled = config?.tools[name] ?? false;
return (
<div key={name} className="border border-line bg-card p-4 shadow-hard">
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-[14px] text-ink">{name}</span>
<span className={`border border-line px-2 py-1 text-[10px] ${enabled ? "bg-ok text-white" : "bg-base text-muted-2"}`}>
{enabled ? "ACTIF" : "INACTIF"}
</span>
</div>
</div>
);
})}
</div>
<button
onClick={onSettings}
className="mt-5 border border-line bg-accent px-4 py-2 text-[13px] font-bold text-white shadow-hard"
>
Configurer les outils
</button>
</Page>
);
}
function Page({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
return (
<div className="scr min-w-0 flex-1 overflow-auto bg-base p-7">
<div className="mx-auto max-w-[1100px]">
<h1 className="m-0 text-xl font-bold text-ink">{title}</h1>
<p className="mb-6 mt-1 text-[13px] text-muted-2">{subtitle}</p>
{children}
</div>
</div>
);
}
function Empty({ text }: { text: string }) {
return <div className="border border-line bg-card p-8 text-center text-muted-2">{text}</div>;
}
-549
View File
@@ -1,549 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ChevronDown, LokiMark, SendIcon } from "../components/Icon";
import { ToolCard } from "../components/ToolCard";
import { MessageContent } from "../components/MessageContent";
import { ProjectChip } from "../components/ProjectChip";
import { ModelSelector } from "../components/ModelSelector";
import type { Message, ToolCall } from "../api/client";
/** Panneau central : barre de contexte, fil de conversation, composer. */
export function ChatPanel() {
const {
selectedModel,
messages,
streaming,
streamingSessionId,
streamContent,
streamThinking,
streamStatus,
streamNotice,
streamTools,
streamPlan,
streamPlanDone,
sendMessage,
currentSessionId,
config,
pendingShell,
approveShell,
rejectShell,
sessions,
stopStreaming,
} = useStore();
const activeTools = config
? Object.values(config.tools).filter(Boolean).length
: 0;
const [draft, setDraft] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll vers le bas à chaque token / message.
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages, streamContent, streamThinking, streamTools, streamPlan, streamPlanDone]);
const submit = () => {
if (!draft.trim() || streaming) return;
sendMessage(draft);
setDraft("");
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const showingStreaming = streaming && currentSessionId === streamingSessionId;
const workingSession = sessions.find((s) => s.id === streamingSessionId);
const empty = messages.length === 0 && !showingStreaming;
return (
<div className="flex min-w-0 flex-1 flex-col bg-base">
{/* Barre de contexte */}
<div className="flex h-[46px] flex-none items-center gap-2 border-b border-line bg-panel px-4">
<Chip>⚑ Invite système</Chip>
<Chip>
<span className="h-2 w-2 border border-line bg-ok" />
{activeTools} outil{activeTools > 1 ? "s" : ""}
</Chip>
<Chip>
Temp{" "}
<b className="text-accent">{config ? config.temperature.toFixed(1) : "—"}</b>
</Chip>
<div className="flex-1" />
<span className="text-[13px] text-muted-3">
{currentSessionId
? `${messages.length} message${messages.length > 1 ? "s" : ""}`
: "aucune session"}
</span>
</div>
{/* Messages */}
<div ref={scrollRef} className="scr flex-1 overflow-auto px-7 py-6">
{empty ? (
<Welcome
sessionCount={sessions.length}
onPick={(text) => setDraft(text)}
/>
) : (
<div className="mx-auto flex max-w-[680px] flex-col gap-5">
{messages.map((m) => (
<Bubble key={m.id} msg={m} />
))}
{showingStreaming && (
<Bubble
msg={{
id: "stream",
session_id: "",
role: "assistant",
content: streamContent,
model: selectedModel,
meta: { tools: streamTools, plan: streamPlan },
created_at: Date.now() / 1000,
}}
pending
pendingStatus={streamStatus}
notice={streamNotice}
thinking={streamThinking}
planDone={streamPlanDone}
/>
)}
{/* La validation shell persiste APRÈS la fin du flux : l'agent
termine son tour en attendant l'utilisateur, donc le streaming
s'arrête — la carte doit rester tant qu'on n'a pas tranché. */}
{pendingShell && (
<ShellConfirm
command={pendingShell}
onApprove={approveShell}
onReject={rejectShell}
/>
)}
</div>
)}
</div>
{/* Composer */}
<div className="flex-none border-t border-line bg-panel px-7 pb-[18px] pt-3.5">
<div className="mx-auto max-w-[680px]">
<div className="border border-line bg-card p-3 shadow-hard" style={{ borderRadius: 8 }}>
<textarea
rows={1}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Envoyer un message à l'agent…"
className="min-h-[40px] w-full resize-none bg-transparent text-[14px] leading-relaxed text-ink outline-none placeholder:text-muted-3"
/>
<div className="mt-1.5 flex items-center gap-2">
<ModeSelector />
<ProjectChip />
<ModelSelector variant="composer" />
{streaming && !showingStreaming ? (
<span className="min-w-0 flex-1 truncate text-[13px] text-accent">
Travail en cours : {workingSession?.title ?? "session ouverte"}
</span>
) : (
<>
<div className="flex-1" />
<span className="text-[13px] text-muted-3">⏎ envoyer · ⇧⏎ ligne</span>
</>
)}
<button
onClick={streaming ? stopStreaming : submit}
disabled={!streaming && !draft.trim()}
className={`flex h-[38px] items-center gap-1.5 border border-line px-4 text-[14px] text-white shadow-hard-accent disabled:opacity-40 ${
streaming ? "bg-warn" : "bg-accent"
}`}
style={{ borderRadius: 7 }}
>
{streaming ? "ARRÊTER" : "ENVOYER"}
{!streaming && <SendIcon />}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
/** Pistes proposées sur l'écran d'accueil (maquette « Loki App »). */
const STARTERS = [
["📄", "Résumer un dossier", "Résume le contenu du dossier "],
["🐞", "Corriger un bug", "Corrige le bug suivant : "],
["✏️", "Refactorer un fichier", "Refactore le fichier "],
["⌨️", "Écrire un script", "Écris un script qui "],
["🔍", "Chercher dans le code", "Cherche dans le code "],
["📊", "Analyser un CSV", "Analyse le fichier CSV "],
["🌿", "Préparer un commit", "Prépare un commit pour "],
["📥", "Générer un rapport", "Génère un rapport sur "],
] as const;
/** Écran d'accueil : accroche, pistes cliquables, renvoi vers l'historique. */
function Welcome({
sessionCount,
onPick,
}: {
sessionCount: number;
onPick: (text: string) => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center px-7 py-10 text-center">
<LokiMark size={44} />
<h1 className="mt-6 text-[34px] font-medium text-ink">
Bienvenue dans Loki
</h1>
<p className="mt-3 max-w-[52ch] text-[15.5px] leading-relaxed text-muted">
Choisissez une piste ci-dessous, ou écrivez directement votre demande.
Tout s'exécute sur cette machine, aucune donnée ne sort.
</p>
<div className="mt-8 flex max-w-[760px] flex-wrap justify-center gap-2.5">
{STARTERS.map(([icon, label, prefill]) => (
<button
key={label}
onClick={() => onPick(prefill)}
className="flex items-center gap-2 whitespace-nowrap rounded-full border border-line px-3.5 py-2 text-[13.5px] text-ink-2 hover:border-accent hover:bg-accent-ghost"
>
<span aria-hidden>{icon}</span>
{label}
</button>
))}
</div>
{sessionCount > 0 && (
<div className="mt-9 flex items-center gap-3 text-[12.5px] text-muted-3">
<span className="h-px w-11 bg-line" />
{sessionCount} conversation{sessionCount > 1 ? "s" : ""} enregistrée
{sessionCount > 1 ? "s" : ""} localement
<span className="h-px w-11 bg-line" />
</div>
)}
</div>
);
}
/** Plan d'exécution affiché avant le travail de l'agent. */
const MODES = [
{ id: "plan", label: "Plan", icon: "🔍", desc: "Lecture seule : analyse et propose, sans rien modifier" },
{ id: "build", label: "Build", icon: "🔨", desc: "Normal : écrit les fichiers, confirme les commandes shell" },
{ id: "yolo", label: "Yolo", icon: "⚡", desc: "Auto : approuve tout, y compris le shell" },
] as const;
/** Sélecteur de mode d'exécution (Plan / Build / Yolo) dans le composer. */
function ModeSelector() {
const { mode, setMode } = useStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
const current = MODES.find((m) => m.id === mode) ?? MODES[1];
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={`flex h-8 items-center gap-1.5 border border-line px-2.5 text-[13px] ${
mode === "plan"
? "bg-info text-white"
: mode === "yolo"
? "bg-accent text-white"
: "bg-card text-ink-2"
}`}
title={current.desc}
>
<span>{current.icon}</span>
<span>{current.label}</span>
<ChevronDown size={11} />
</button>
{open && (
<div className="absolute bottom-10 left-0 z-20 w-64 border border-line bg-card p-1 shadow-hard">
{MODES.map((m) => (
<button
key={m.id}
onClick={() => {
setMode(m.id);
setOpen(false);
}}
className={`flex w-full flex-col items-start gap-0.5 px-2 py-2 text-left hover:bg-base ${
m.id === mode ? "bg-base" : ""
}`}
>
<span className="text-[13px] text-ink">
{m.icon} {m.label}
</span>
<span className="text-[11px] leading-tight text-muted-2">{m.desc}</span>
</button>
))}
</div>
)}
</div>
);
}
function PlanCard({
steps,
done,
live,
}: {
steps: string[];
done?: number[];
live?: boolean;
}) {
const doneSet = new Set(done ?? []);
// Étape active = la première non validée, uniquement pendant le stream.
const activeIdx = live
? steps.findIndex((_, i) => !doneSet.has(i))
: -1;
const doneCount = doneSet.size;
return (
<div
className="mb-[11px] overflow-hidden border border-line bg-card shadow-hard-sm"
style={{ borderRadius: 7 }}
>
<div className="flex items-center gap-2 border-b border-line-soft px-3 py-2">
<span className="font-pixel text-[9px] text-accent">PLAN</span>
<span className="text-[12px] text-muted-2">
{doneCount > 0
? `${doneCount}/${steps.length} validée${doneCount > 1 ? "s" : ""}`
: `${steps.length} étape${steps.length > 1 ? "s" : ""}`}
</span>
</div>
<ol className="m-0 list-none px-3 py-2">
{steps.map((s, i) => {
const isDone = doneSet.has(i);
const isActive = i === activeIdx;
return (
<li
key={i}
className={`flex gap-2 py-[3px] text-[13px] ${
isDone ? "text-muted-2" : "text-ink-2"
}`}
>
<span
className={`flex h-[18px] w-[18px] flex-none items-center justify-center border text-[11px] ${
isDone
? "border-ok bg-ok text-white"
: isActive
? "border-accent bg-base text-accent"
: "border-line bg-base text-ink"
}`}
>
{isDone ? "✓" : isActive ? "…" : i + 1}
</span>
<span className={`min-w-0 ${isDone ? "line-through" : ""}`}>
{s}
</span>
</li>
);
})}
</ol>
</div>
);
}
function Bubble({
msg,
pending,
pendingStatus,
notice,
thinking,
planDone,
}: {
msg: Message;
pending?: boolean;
pendingStatus?: string;
notice?: string | null;
thinking?: string;
planDone?: number[];
}) {
const time = new Date(msg.created_at * 1000).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
if (msg.role === "user") {
return (
<div className="flex gap-3">
<div className="font-pixel flex h-[34px] w-[34px] flex-none items-center justify-center border border-line bg-card-deep text-[11px] text-ink">
M
</div>
<div className="flex-1">
<div className="mb-1.5 flex items-baseline gap-2">
<span className="text-[14px] text-ink">VOUS</span>
<span className="text-[13px] text-muted-3">{time}</span>
</div>
<div className="border border-line bg-card px-[14px] py-3 text-[14px] leading-snug text-ink shadow-hard-sm whitespace-pre-wrap" style={{ borderRadius: 7 }}>
{msg.content}
</div>
</div>
</div>
);
}
return (
<div className="flex gap-3">
<div className="grid h-[34px] w-[34px] flex-none place-items-center rounded-[9px] border border-accent bg-accent-ghost">
<div className="bg-accent" style={{ width: 10, height: 10, borderRadius: 2 }} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-baseline gap-2">
<span className="text-[14px] text-ink">LOKI</span>
<span className="text-[13px] text-muted-3">
{msg.model ? `${msg.model} · ` : ""}
{time}
</span>
</div>
<ReasoningPanel
text={thinking ?? msg.meta?.thinking ?? ""}
live={!!pending}
/>
{(msg.meta?.plan?.length ?? 0) > 0 && (
<PlanCard steps={msg.meta!.plan!} done={planDone} live={!!pending} />
)}
{(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => (
<ToolCard key={i} call={t} />
))}
{notice && (
<div className="mb-2 border border-line bg-card px-3 py-2 text-[13px] text-warn">
{notice}
</div>
)}
{(msg.content || pending) && (
<div className="text-[14px] leading-[1.5] text-ink-2">
{msg.content ? (
<MessageContent text={msg.content} />
) : (
<span className="text-muted-2">{pendingStatus || "Génération…"}</span>
)}
{pending && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-accent align-middle" />
)}
</div>
)}
{!pending && msg.meta?.stats && (
<div className="mt-2 flex items-center gap-2.5 text-[12px] text-muted-3">
{msg.meta.stats.tokens_per_sec != null && (
<span className="text-accent-2">
{msg.meta.stats.tokens_per_sec} tok/s
</span>
)}
<span>{msg.meta.stats.eval_count} jetons</span>
{msg.meta.stats.prompt_eval_count > 0 && (
<span>· {msg.meta.stats.prompt_eval_count} en entrée</span>
)}
</div>
)}
</div>
</div>
);
}
/** Panneau « Raisonnement » : repliable et redimensionnable (poignée en bas). */
function ReasoningPanel({ text, live }: { text: string; live: boolean }) {
const [open, setOpen] = useState(live);
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (live) setOpen(true);
}, [live]);
useEffect(() => {
if (open && live) {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
}
}, [text, open, live]);
if (!text) return null;
return (
<div className="mb-[11px] overflow-hidden border border-line bg-card shadow-hard-sm" style={{ borderRadius: 7 }}>
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
<ChevronDown
size={12}
className={`text-ink-2 transition-transform ${open ? "" : "-rotate-90"}`}
/>
<span className="text-[13px] font-medium text-ink">Raisonnement</span>
{live && (
<span className="flex items-center gap-1 text-[12px] text-accent">
<span className="h-2 w-2 animate-pulse border border-line bg-accent" />
en cours…
</span>
)}
<span className="ml-auto text-[12px] text-muted-3">
{open ? "réduire" : "afficher"}
</span>
</button>
{open && (
<div
ref={bodyRef}
className="scr max-h-[200px] min-h-[60px] resize-y overflow-auto whitespace-pre-wrap border-t border-line-soft bg-card-deep px-3 py-2 text-[12.5px] leading-relaxed text-on-dark-2"
>
{text}
</div>
)}
</div>
);
}
function ShellConfirm({
command,
onApprove,
onReject,
}: {
command: string;
onApprove: () => void;
onReject: () => void;
}) {
return (
<div className="ml-[46px] overflow-hidden border border-line bg-card shadow-hard" style={{ borderRadius: 7 }}>
<div className="flex items-center gap-2 border-b border-line px-3 py-2.5">
<span className="text-[14px] text-accent">run_shell</span>
<span className="text-[13px] text-muted-2">· commande sensible à valider</span>
</div>
<div className="px-3 py-3">
<pre className="m-0 mb-3 overflow-auto whitespace-pre-wrap border border-line bg-card-deep px-3 py-2.5 text-[12.5px] text-on-dark">
$ {command}
</pre>
<div className="flex items-center gap-2">
<button
onClick={onApprove}
className="flex h-[32px] items-center gap-1.5 border border-line bg-accent px-3.5 text-[13px] text-white shadow-hard-accent"
style={{ borderRadius: 7 }}
>
Approuver &amp; exécuter
</button>
<button
onClick={onReject}
className="flex h-[32px] items-center border border-line bg-card px-3.5 text-[13px] text-ink-2"
style={{ borderRadius: 7 }}
>
Refuser
</button>
</div>
</div>
</div>
);
}
function Chip({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-7 items-center gap-1.5 border border-line bg-card px-2.5 text-[13px] text-ink-2">
{children}
</div>
);
}
Loaded 100 of 348 files, more files were not shown because too many files have changed in this diff. Show more