Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 4b859c4eb6 Conteneur : façade Rust en frontal, binaires mis à jour au démarrage
L'image embarque loki-rs (étape Alpine, binaire musl statique) et
l'entrypoint en fait la porte d'entrée : `loki-rs serve` sur le port
public, le Go derrière lui sur 127.0.0.1:8091. LOKI_FACADE=0 revient à
l'ancien schéma si la façade pose problème.

Au démarrage, le loki-rs de l'image installe la dernière release
« rolling » dans /data/bin/<build>/ ; l'entrypoint prend ensuite loki et
loki-rs du même dossier (which-bin), et un lien dans /usr/local/sbin fait
que `docker exec loki loki …` vise lui aussi la version installée. C'est
toujours le loki-rs de l'image qui met à jour : un binaire publié
défectueux ne peut pas bloquer le correctif suivant.

CI : job Rust (fmt, clippy -D warnings, tests). Publication : la release
« rolling » part à chaque push sur main avec le même numéro de run que
l'image (LOKI_BUILD) ; IMAGE_ABI, source unique, empêche d'installer des
binaires qui exigent une image plus récente. manifest.json est envoyé en
dernier.

Message corrigé quand l'image est plus récente que la release : rien à
tirer dans ce cas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:33:18 +02:00
MichaelandClaude Opus 5.5 0a6914c936 Façade Rust loki-rs : proxy devant le Go et mise à jour au démarrage
Premier lot de la migration vers Rust. Le binaire loki-rs :

- serve : prend le port public (8090), lance `loki web 8091` en écoute
  locale et lui relaie tout — HTTP, flux SSE trame par trame, WebSocket.
  Les en-têtes de bout en bout passent intacts (Host, Authorization,
  Origin, Sec-Fetch-*, X-Forwarded-Proto : le Go en tire ses contrôles
  d'accès) ; X-Loki-Via entrant est effacé, un client ne doit jamais
  pouvoir se faire passer pour le tunnel. Go mort : relancé avec attente
  croissante, 502 lisible entre-temps.
- self-update : lit la release « rolling », installe loki et loki-rs dans
  $LOKI_HOME/bin/<build>/ après vérification SHA-256, bascule par
  réécriture atomique de release.json. Les versions s'ordonnent par numéro
  de run CI ; une ABI d'image différente n'est jamais installée. Toute
  erreur laisse l'état précédent, le démarrage n'est jamais bloqué.
  Le HTTPS passe par curl (présent dans l'image) : pas de pile TLS ici.
- which-bin : dossier d'où l'entrypoint prend les deux binaires, toujours
  ensemble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:30:09 +02:00
MichaelandClaude Opus 5.5 342f787eff Web : adresse d'écoute réglable par LOKI_WEB_BIND
La façade Rust du conteneur prend le port public et lance `loki web`
derrière elle : le Go doit alors n'écouter qu'en local, sinon il resterait
joignable en direct sans passer par la façade. Défaut inchangé (0.0.0.0).

Plan du lot 1 de la migration Rust joint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:31:28 +02:00
MichaelandClaude Opus 5.5 f1f73cfc50 Spec : migration Rust, lot 1 — façade et mise à jour au démarrage
La réécriture en Rust se fait par lots, Rust en production dès le premier :
une façade loki-rs prend le port 8090 et proxifie vers le Go, qui passe en
écoute locale sur 8091. Chaque lot suivant lui retire des routes.

Le même lot fait que le conteneur prend la dernière version au démarrage :
release GitHub « rolling » publiée à chaque push sur main, binaires vérifiés
par SHA256SUMS et posés dans /data/bin, garde-fou d'ABI d'image, jamais de
boot bloqué.

La façade efface X-Loki-Via à l'entrée : sur l'écoute directe, l'en-tête
pouvait être forgé pour contourner le contrôle d'hôte anti-rebinding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:27:59 +02:00
LogiFlow 6ea661d705 Merge branch 'claude/bold-faraday-11i7lh' : Strata sur un SSD dédié (Unraid)
Strata sur un SSD dédié (Unraid)
2026-10-08 12:30:44 +02:00
Claude e236069c92 Strata sur un SSD dédié : place libre mesurée sur /data/strata, caches pip/HF hors de l'image
- strataDetect mesure l'espace libre de /data/strata (et non /data) : un SSD
  monté à cet endroit est celui qui compte pour l'installation.
- L'installeur reçoit XDG_CACHE_HOME=/data/strata/cache : les caches pip et
  Hugging Face ne remplissent plus /root, c.-à-d. l'image Docker d'Unraid.
- docker-compose.unraid.yml + README : monter un pool SSD en /data/strata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLbFcZj6mJjDpWvCpcChGA
2026-10-08 09:33:26 +00:00
LogiFlow 6afe07a823 Merge branch 'claude/wizardly-lamport-jfgrq5' : MTP sans recharger le modèle en brouillon
MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
2026-10-08 11:18:00 +02:00
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

probeSpec reconnaît ce cas (os.SameFile), specArgs l'ignore et le dit :
la tête MTP intégrée sert de brouillon sans -md. L'éditeur ne propose
plus le modèle principal dans la liste des brouillons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 09:13:56 +00:00
LogiFlow a463ccbd9f Merge branch 'claude/wizardly-lamport-jfgrq5' : arrêt du moteur sans course avec la relance, VRAM rendue
Arrêt du moteur : attendre tout l'arbre (VRAM rendue avant la relance)
2026-10-08 10:08:13 +02:00
Claude 393004cb06 Arrêt du moteur : attendre tout l'arbre, pas seulement le chef
En conteneur, userSvcStop n'attendait que le processus `loki serve`. Avec
Strata, le moteur qui tient la VRAM (cache d'experts, ~9 Go) est un autre
processus qui finit de s'arrêter après le serveur Python : la relance
suivante démarrait llama-server sur une carte encore pleine (« cudaMalloc
failed: out of memory » à l'encodeur de vision) et Strata mesurait la RAM
disponible avec l'ancien moteur encore chargé (mode mmap choisi à tort).

stopProcTree relève l'arbre (session, groupe et descendants) avant tout
signal, SIGTERM puis SIGKILL aux survivants, et ne revient qu'une fois
tout parti.

Diagnostic : une pile d'appels citant load_model ne passe plus pour une
nouvelle tentative (la cause mémoire était perdue), et le chat donne
l'échec de chargement au lieu de « il démarre, réessaie ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 07:18:53 +00:00
MichaelandClaude Opus 5.5 7d614324a0 Merge branch 'fix/tune-lock-race' : CI verte, verrou de l'optimiseur sans course
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:11 +02:00
MichaelandClaude Opus 5.5 853e59c085 Optimiseur : le ménage du verrou ne croise plus sa prise ni son rendu
TestTuneRecoverNeverReapsOwnLock échouait au hasard sous Linux (2 fois sur
5 déjà en v0.15.0, CI rouge). Le ménage (tuneReapStale) déplaçait un verrou
vivant, le relisait puis le remettait en place ; si release() passait entre
les deux, il ne trouvait plus le fichier et rendait l'inscription. Le verrou
remis en place devenait orphelin, et le ménage suivant le prenait pour une
optimisation interrompue : moteur relancé, preset « rétabli » à tort.

Un mutex de processus (tuneFileMu) sérialise désormais les opérations sur
le fichier — prise, réécriture, rendu, déplacement-relecture du ménage.
L'arrêt d'un essai orphelin et le retour du preset restent hors du mutex.
Vérifié sous Linux : 40 passages sans échec, suite complète verte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:08 +02:00
MichaelandClaude Opus 5.5 e7fb63b1dd Merge branch 'fix/strata-ram-mmap' : experts de Strata selon la RAM disponible, mode mmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:26:54 +02:00
MichaelandClaude Opus 5.5 6aaf9360b4 Strata : experts placés selon la RAM disponible, mode « depuis le disque » (mmap)
Vécu : 62 Go de RAM dont 18 déjà pris par le reste du serveur. Le mode
« carte d'appoint hors RAM » avait été choisi sur la RAM TOTALE, et le
premier lancement, chargé d'écrire experts.bin, a monté le moteur à ~41 Go
de mémoire anonyme : le noyau l'a tué (OOM).

- Placement décidé AU LANCEMENT sur la RAM disponible (MemAvailable), plus
  sur la RAM totale ; le mode retenu est écrit dans le journal du moteur.
- Nouveau mode « disque » : --mmap-experts, les experts sont lus dans les
  fichiers du modèle par le cache système, rien n'est chargé au démarrage.
  Choisi d'office quand la RAM disponible ne suffit pas.
- Plus de premier lancement qui écrit experts.bin : les modes « hors RAM »
  et « experts.bin mappé » ne servent que si le fichier existe déjà.
- Réglage « Experts » dans la fenêtre de Strata : auto, en RAM, ou depuis le
  disque ; la configuration montre le mode du dernier lancement et la RAM
  disponible de la machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:26:50 +02:00
MichaelandClaude Opus 5.5 c2a2cc5748 Merge branch 'feat/ajean-lot-images' : recherche d'images, aperçu du navigateur, vignettes, journal d'installation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:04:54 +02:00
MichaelandClaude Opus 5.5 ac6d8ad144 Journal d'installation : la progression de Strata visible, plus de lignes en double
- Les barres de progression qui se redessinent avec \r (téléchargement du
  modèle par l'installeur de Strata) n'apparaissaient jamais : le journal ne
  découpait que sur \n et restait figé pendant tout le téléchargement. Une
  version toutes les 10 s est montrée ; \r\n reste une fin de ligne.
- Le suivi du job relançait une requête chaque seconde sans attendre la
  précédente : quand le serveur était occupé, deux réponses ajoutaient les
  mêmes lignes au journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:54:09 +02:00
MichaelandClaude Opus 5.5 5016396998 Repris d'AJEAN : recherche d'images, aperçu du navigateur, vignettes, liens d'images
- web_images (amont v0.17.7) : recherche d'images DuckDuckGo, pendant de
  web_search ; les images du web s'affichent dans le fil avec la loupe, sans
  Referer, et disparaissent si le site les refuse. Schéma minimal pour tenir
  le budget du préambule.
- Aperçu en direct du navigateur piloté (cu_autoshot.go) : une capture après
  chaque action browser_* qui change la page, gardée en RAM, jamais envoyée au
  modèle, retirée du journal en fin de tour ; une carte dans le fil, en fondu.
- Vignettes calculées par le serveur (?thumb=, amont v0.17.9) : ~560 px au
  lieu de l'original (7 Ko au lieu de 550 Ko sur une capture), chargées deux
  par deux avec un nouvel essai ; la loupe ouvre l'original.
- Un lien vers une image ([graphique](graph.png)) l'affiche au lieu d'un
  bouton de téléchargement.
- Chrome du computer use tué avec Loki sous Linux (Pdeathsig), même en cas
  d'arrêt brutal : plus de navigateur orphelin dans le conteneur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:30:05 +02:00
MichaelandClaude Opus 5.5 502f23e833 staticcheck : decryptChatStores retirée (remplacée par decryptChatStoresSkipping)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:58:40 +02:00
MichaelandClaude Opus 5.5 634cfb61f3 Merge branch 'feat/moteur-strata' : moteur Strata, correctifs et presets repris d'AJEAN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:40:10 +02:00
MichaelandClaude Opus 5.5 ecff7de5a3 Presets : glisser-déposer, pastilles contexte et capacités, prompt système dans l'éditeur
Repris d'AJEAN et adapté à Loki :
- liste réordonnable par glisser-déposer (SortableJS 1.15.6, MIT), ordre
  gardé côté serveur (/api/presets/order) et suivi par le sélecteur de
  l'en-tête ; le clic de fin de glissement ne bascule pas de preset
- pastille de contexte (32K, 128K, 40K…) et pastille « capacités » : œil
  quand le preset lit les images (MMPROJ, API externe déclarée multimodale,
  Strata), ampoule + niveau quand il raisonne
- éditeur : prompt système DE CE preset (rangé en base, sysprompt:<id>),
  projecteur vision sur le CPU (--no-mmproj-offload, visible seulement avec
  un mmproj)
- les projecteurs mmproj ne sont plus proposés comme modèle (éditeur et
  sélecteur de l'en-tête)
- l'aide « ? » reste survolable sur un réglage grisé

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:36:59 +02:00
MichaelandClaude Opus 5.5 127013b32b Correctifs repris d'AJEAN : chiffrement, preset externe, heartbeat, commande du moteur
- Désactivation du chiffrement (amont cac0cda, 19918c6) : une valeur
  illisible ne bloque plus tout (quarantaine datée + copie du keyvault), et la
  reprise au démarrage déchiffre aussi les conversations au lieu de retirer la
  clé après les seules pages. Propre à Loki : les images de conversation
  (chatimg/) sont maintenant déchiffrées elles aussi — elles restaient
  chiffrées sans clé. Les .bak chiffrés devenus illisibles sont retirés.
- Preset externe (amont #95) : `loki serve` sort sans erreur (plus de boucle
  systemd « MODEL non défini »), le pré-vol l'accepte, le superviseur du
  conteneur ne le prend plus pour un plantage.
- Unité systemd du moteur : TimeoutStopSec=5 (bascule de preset bloquée 90 s
  en pleine génération).
- Heartbeat SSE (amont #105) : plus d'écriture après le retour du handler.
- Paramètres → Configuration : commande exacte du moteur copiable (clé API
  masquée, Strata compris) et nombre de couches du modèle à côté de NGL, lu
  dans l'en-tête GGUF (amont #108, #43) ; aussi dans l'éditeur de preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:28:42 +02:00
MichaelandClaude Opus 5.5 4a9a5a1712 Moteur Strata : un second moteur, choisi par preset (ENGINE=strata)
Strata (github.com/Niko1221/Strata, MIT) fait tourner Qwen3.8 Flash Next
(MoE 125 B) sur une carte de joueur en répartissant les experts entre VRAM,
RAM et disque. Intégration reprise d'AJEAN (backend_moe.go, « AJEAN MoE
1.0 ») : paquet figé de la release moe-v1.0, vérifié par SHA-256.

- backend_strata.go : détection de la machine, quant conseillé, installation
  en tâche (même suivi que llama.cpp), preset ENGINE=strata, lancement du
  serveur de Strata par `loki serve`, réglages d'un modèle installé
- pré-vol, vision, liste des presets, journal de chargement : Strata reconnu
- proxy OpenAI et relais : Host local pour Strata (il refuse un Host inconnu)
- benchmark et optimiseur refusés sur Strata (protocole propre à llama-server)
- UI : ligne Strata dans Paramètres → Moteur (visible aussi avec le moteur de
  l'image), fenêtre d'installation et de réglages, moteur actif affiché
- image : python3 + python3-venv ; compose : memlock illimité

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:03:22 +02:00
MichaelandClaude Opus 5.5 5dd14c9de5 Version 0.15.0
Version consacrée à la vitesse sans dénaturer le modèle : les lots 1, 2 et 3
de performance. Ce qui est sans perte est actif d'office ; tout ce qui change
ce que voit le modèle, le placement ou les slots attend une clé.

- Version passée à 0.15.0 (run.go, versioninfo.json, ressources Windows
  régénérées par goversioninfo seul — icônes inchangées).
- Notes de release réécrites : actif d'office, opt-in et leurs clés, ordre de
  test conseillé, ce qui a été écarté (cache-reuse, context-shift, KV
  quantifié par défaut) et pourquoi.
- README : une section « Performance » rassemble toutes les clés (défaut,
  effet, prix ou zone grise), les outils sans clé, l'ordre de test et les
  pistes écartées ; le détail de chaque clé y est déplacé depuis
  « Fonctionnalités », qui n'y renvoie plus que par un lien.
- NOTICE inchangé : rien de ces lots ne reprend OpenFox ni AJEAN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:32:27 +02:00
MichaelandClaude Opus 5.5 5d37bef0ce Moteur : une bascule plus récente périme la vérification du rendu, plan et retour en POST
La vérification du rendu du gabarit après une mise à jour attend jusqu'à
vingt minutes que le nouveau moteur réponde. Un retour à la version
précédente, une autre version installée ou une seconde mise à jour pendant
cette attente la laissaient courir : elle comparait alors l'ancien moteur à
celui d'une autre bascule, ou écrasait le relevé de la suivante.

- Chaque bascule ouvre une génération ; une vérification périmée s'arrête
  sans rien ranger, et son relevé n'écrase plus le suivant. Retour et
  « utiliser » effacent le relevé, qui parlerait d'un moteur arrêté.
- /api/engine/plan (registre) et /api/engine/rollback (configuration et
  redémarrage) refusent tout autre verbe que POST — l'interface n'utilise
  que POST.
- Tests : vérification annulée en attente, relevé périmé ignoré, GET refusé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:24:21 +02:00
MichaelandClaude Opus 5.5 5b834d54b2 Optimiseur : la reprise toutes les 30 s ne prend plus un verrou vivant pour périmé
Depuis que le processus web refait la reprise des optimisations
interrompues toutes les 30 s, elle tourne pendant que ce même processus
prend et rend ses propres verrous (bouton « Optimiser… », application d'un
résultat). Trois fenêtres lui faisaient écarter un verrou bien vivant : le
fichier créé avant l'inscription du verrou comme nôtre, l'inscription
retirée avant le fichier (sous Windows, un fichier qu'un autre lit ne se
supprime pas), et le renommage d'un verrou neuf pris entre la lecture et le
ménage. En phase « application », le preset tout juste vérifié était alors
défait, et le moteur relancé.

- Inscription avant la création du fichier, retrait après lui ; un
  identifiant de prise (nonce) distingue deux verrous du même processus dans
  la même seconde.
- Au moment de le rendre, le verrou est réécrit neutre (rien à défaire,
  rien à relancer, aucun essai), puis retiré avec quelques essais.
- Le ménage relit ce qu'il vient de déplacer : un autre verrou, ou un verrou
  vivant, est remis en place par un lien (jamais par-dessus un verrou neuf).
- La relance d'après reprise passe par serviceAction : une optimisation
  neuve qui a pris le verrou entre-temps garde le moteur arrêté.
- Test : 60 prises et rendus en phase « application » sous une reprise en
  boucle, aucune relance ; échoue sur l'ancien code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:24:20 +02:00
MichaelandClaude Opus 5.5 c24472b91f Moteur : encart « version recommandée », retour à la version précédente, garde du rendu du raisonnement
Le lot 1 savait dire qu'un moteur officiel antérieur à b10864 évince trop tôt
les points de reprise des hybrides, mais laissait l'utilisateur chercher seul
quelle version prendre. Le panneau Moteur propose maintenant la version
recommandée, sur clic seulement, et garde un chemin de retour sans réseau.

La mise à jour elle-même n'est pas neutre pour le prompt : depuis b10763,
llama-server active preserve_reasoning par défaut, et un gabarit qui retirait
la réflexion des tours passés (Qwen3.6, clear_thinking) la rend alors — vide,
puisque Loki ne la renvoie pas sans REASONING_ECHO. Avant la bascule, Loki lit
le gabarit chargé et le verdict de la sonde ; si le rendu va changer, la
confirmation le dit et propose REASONING_PRESERVE=off (case cochée seulement
quand c'est établi : sur Qwen3.8, off changerait à son tour le rendu).
L'utilisateur choisit. Après la bascule, le rendu de conversations
synthétiques est comparé via /apply-template entre l'ancien et le nouveau
moteur, et le premier écart est signalé.

- encart sans réseau (build de confiance < b10864) : gains cités (points de
  reprise, MTP rapide b11009, MTP qwen4exp b11331), SPEC reste off
- POST /api/engine/plan, sur clic : dernier build publié, vérifié ≥ b10864 et
  tag figé existant ; sans réseau, une erreur et rien d'installé
- POST /api/engine/rollback + fichier engine/previous-bin : la version quittée
  (gardée par le ménage du lot 1) ; le retour la rend « précédente » à son tour
- REASONING_PRESERVE=off posée dans le preset actif, seulement si le nouveau
  moteur connaît --no-reasoning-preserve et que la clé est vide
- comparaison du rendu en tâche de fond, après chargement et changement de
  build_info (un ancien moteur survivant ne fausse pas le verdict)
- aucune clé nouvelle ; rien au démarrage ; aucune mise à jour sans clic

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:12:19 +02:00
MichaelandClaude Opus 5.5 911ffbfeaf Moteur : corrections de relecture — avec LOAD_GUARD=off, l'avis ne parle plus de refus
L'avis rendu quand le refus attend les cartes du moteur (VRAM NVIDIA seule)
annonçait « refus seulement si les cartes listées par le moteur le
confirment » même avec LOAD_GUARD=off, où aucun refus n'est possible. Il
retombe alors sur l'avis ordinaire.

- Test : VRAM NVIDIA seule avec LOAD_GUARD=off, et aucun avis qui évoque un
  refus quand la clé d'échappement est posée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:56:00 +02:00
MichaelandClaude Opus 5.5 5f7203a935 Interface : une seule lecture nvidia-smi à l'ouverture de l'éditeur de preset
À l'ouverture, l'éditeur demande en parallèle la taille du cache de prompts
(et celle du second slot) et les avis MoE. Chaque aperçu lançait son
nvidia-smi pour lire les mémoires totales — trois processus pour une réponse
identique, la mémoire d'une carte ne bougeant pas.

- La lecture brute des mémoires totales est partagée dix secondes ; le verrou
  tenu pendant la lecture fait attendre les demandes simultanées sur la
  même. La sélection des cartes (CUDA_VISIBLE_DEVICES) s'applique ensuite,
  comme avant.
- Un échec est gardé le même délai : une salve ne relance pas trois lectures
  vouées à échouer. « loki serve » ne lit qu'une fois : rien n'y change.
- Test : trois demandes simultanées, une lecture, chaque sélection juste ;
  échec non relancé dans la salve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:53:17 +02:00
MichaelandClaude Opus 5.5 4b659ac186 Raisonnement : NUDGE_IN_TOOL décide sur le verdict de la forme de la requête
La sonde de gabarit gardait le verdict de la DERNIÈRE forme sondée (outils,
chat_template_kwargs, reasoning_effort), quelle qu'elle soit. NUDGE_IN_TOOL
pouvait donc glisser le rappel de budget dans un résultat d'outil sur la foi
d'un « préfixe instable » conclu pour une autre forme — un autre jeu
d'outils, la réflexion coupée, un autre niveau — dont le rendu n'a rien à
voir.

- Les verdicts sont aussi rangés par forme (empreinte tplShape, au plus 16,
  la plus ancienne cède) ; tplCapsFor rend celui de la forme demandée.
- NUDGE_IN_TOOL calcule la forme de la requête courante (mêmes outils,
  kwargs et niveau que ceux envoyés) et ne place le rappel dans le résultat
  que si CETTE forme est « instable » pour ce modèle ; forme jamais sondée ou
  inconnue : message à part, comme sans la clé. Une relance outils coupés ou
  tool_choice « none » (forme que la sonde ne voit jamais) se replie aussi.
- L'affichage (/api/perf/summary) et REASONING_ECHO gardent le dernier
  verdict, comme avant. Sans la clé, rien ne change.
- Tests : verdict par forme dans la sonde, autre forme / autres kwargs /
  autre niveau refusés, bout à bout avec le verdict d'une autre forme
  (échouait sur l'ancien code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:50:15 +02:00
MichaelandClaude Opus 5.5 ac40a2556a Optimiseur : « loki tune » refuse un moteur qui n'est pas celui de son LOKI_HOME
En ligne de commande, « loki tune » et le processus web avec des LOKI_HOME
différents (sudo qui retire un LOKI_HOME exporté, variable posée dans un seul
shell) ne se voient pas : le verrou tombait dans un dossier que l'interface ne
lit pas, l'arrêt du « vrai » moteur visait celui d'une autre configuration, et
l'essai chargeait à côté d'un moteur bien vivant — VRAM saturée, mesures
fausses, relance possible par l'interface en pleine mesure.

- Avant de commencer, la CLI regarde ce qui répond sur le port de son
  LOKI_HOME : un llama-server (/health 200, 503 ou 401) alors que ce dossier
  dit son moteur arrêté, un 401 sur /props avec sa clé d'API, ou un
  model_path qui n'est pas son MODEL (même fichier vérifié par os.SameFile)
  — refus, avec LOKI_HOME en clair et la marche à suivre (relancer avec le
  bon LOKI_HOME, ou le bouton « Optimiser… »).
- Tout ce qui ne conclut pas laisse passer : rien n'écoute, autre service
  (404), /props absent d'un moteur ancien, chemin illisible d'ici. Le
  processus web n'est pas concerné : il fait foi pour son moteur.
- Test : faux moteurs httptest pour chaque cas, refus et passages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:41:53 +02:00
MichaelandClaude Opus 5.5 a325e98dad Optimiseur : un « loki tune » tué par kill -9 ne laisse plus le moteur arrêté
Le verrou périmé n'était repris qu'au démarrage du processus web. Un
« loki tune » en ligne de commande tué sans ses defers (kill -9, terminal
perdu) laissait donc le vrai moteur arrêté — et peut-être l'essai en VRAM —
jusqu'au prochain redémarrage de l'interface.

- Le processus web refait la reprise toutes les 30 s, avec la même logique
  qu'au démarrage : essai orphelin arrêté (identité complète vérifiée),
  application interrompue défaite, moteur relancé seulement s'il tournait
  avant, ni sur un preset externe ni s'il est déjà reparti.
- Coût : un stat par tic tant qu'aucun verrou n'existe ; pendant une
  optimisation vivante, la lecture du verrou et de l'identité de son
  propriétaire. Une seule goroutine, endormie sur le tic.
- Test : relance au tic qui suit la mort du propriétaire, une fois ; rien
  sans verrou, rien pendant une optimisation vivante, rien pour un moteur qui
  ne tournait pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:37:30 +02:00
MichaelandClaude Opus 5.5 f0fef91773 Moteur : LOAD_GUARD ne refuse plus sur la seule VRAM NVIDIA (Vulkan, cartes mixtes)
La borne basse des poids en RAM se calculait avec la VRAM de nvidia-smi. Un
moteur Vulkan sur une machine NVIDIA + AMD, sans --device, place aussi des
poids sur la carte AMD : la borne était fausse et le lancement refusé à tort.
Un moteur ROCm à côté d'une carte NVIDIA n'utilise pas du tout celle-ci.

- Le refus se décide désormais sur les cartes que le moteur liste lui-même
  (--list-devices, dans l'environnement du lancement, sélection GPU
  comprise), toutes additionnées : rien au-delà ne peut recevoir de poids, la
  borne est sûre. Une carte listée sans servir (iGPU) ne fait que rendre le
  refus plus rare.
- Lu seulement quand un refus se profile sur la VRAM NVIDIA : aucun lancement
  de plus dans le cas ordinaire. Les cartes de SPLIT_MODE sont reprises si
  elles sont déjà lues.
- Liste illisible, vide ou carte à 0 Mio (déjà pleine) : avertissement,
  jamais de refus. Même chose dans l'aperçu de l'éditeur, qui reprend la
  dernière liste complète du moteur (devices.json) sans le lancer.
- Le reste ne bouge pas : VRAM inconnue, --device, --rpc, mémoire unifiée.
- Tests : faux refus Vulkan évité, nvidia-smi seul n'est qu'un avis, lecture
  déclenchée seulement avec un refus en vue, somme des cartes (JSON relu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:33:46 +02:00
MichaelandClaude Opus 5.5 a4db6d4da1 Moteur : un refus de LOAD_GUARD n'est plus relancé en boucle par systemd
Le refus d'un chargement résident trop gros pour la RAM faisait sortir
« loki serve » en erreur (code 1) ; l'unité loki-engine (Restart=on-failure,
RestartSec=3) le relançait toutes les trois secondes, sans fin, pour un échec
qui ne dépend que du preset et de la machine.

- Le refus sort avec un code dédié, 78 (EX_CONFIG) ; mustExit respecte le
  code porté par l'erreur, toutes les autres gardent 1.
- L'unité écrite par « loki install » porte RestartPreventExitStatus=78 ;
  les autres échecs restent relancés.
- Unité d'une version précédente (« loki update » ne réécrit pas les unités,
  il tourne sans droits root) : reconnue à coup sûr (parent systemd,
  INVOCATION_ID, unité et compléments lus sans la directive), le refus y sort
  sans erreur pour ne pas boucler, avec la marche à suivre au journal.
  Même chose sous launchd, qui relance toute sortie non nulle.
- Le moteur d'essai de l'optimiseur garde toujours le vrai code. Le
  conteneur ne relance jamais le moteur de lui-même : rien à y changer, la
  raison reste au journal et l'interface la montre (modelLoadError).
- Tests : directive reconnue (et seulement elle), code de sortie du refus,
  enveloppé ou non, unité générée (Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:29:13 +02:00
MichaelandClaude Opus 5.5 d50c8a56c6 Interface : « n-grammes (mod) » brut rejoint SPEC=ngram, migration proposée pour les anciens presets
L'éditeur de preset offrait deux fois les n-grammes du contexte : l'option
« n-grammes du contexte » (SPEC=ngram, bornes explicites et garde-fous de
Loki) et l'ancienne « n-grammes (mod) », qui écrivait --spec-type ngram-mod
tel quel dans EXTRA_ARGS. Le sélecteur n'écrit plus que SPEC.

- Option brute ngram-mod retirée de la liste.
- Un preset qui porte encore --spec-type ngram-mod l'affiche sous son nom
  (« réglé à la main dans EXTRA_ARGS »), et une ligne propose de passer à
  SPEC=ngram en disant ce qu'elle retirerait (--spec-type, --spec-ngram-*,
  --spec-draft-* : laissés, ils feraient ignorer SPEC ou refuser les
  n-grammes au lancement).
- Rien n'est réécrit à l'ouverture ni sans clic, et le moteur lit toujours le
  drapeau brut : les presets existants tournent comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:16:36 +02:00
MichaelandClaude Opus 5.5 432308c5b2 Compaction : une compaction en cours de tour ne fait plus disparaître le rappel des pages lues
Après une compaction en cours de tour, generate rangeait la vue publiée en
retirant TOUS les messages système de tête. Ceux que la vue d'envoi avait
posés (préambule, prompt du preset, contexte du projet ou bloc figé) n'ont
rien à faire dans l'historique ; mais le rappel « [MEMORY PAGES REMINDER] »,
posé par une compaction de début de tour, en fait partie — il partait avec
eux, et le modèle perdait la liste des pages à relire.

- historyFromView : un système de tête n'est gardé que s'il figurait en tête
  de l'historique d'où la vue est partie (la compaction garde la tête telle
  quelle) ; un système fusionné par InjectSkills devant le préambule est rendu
  tel qu'il était.
- Même correction pour la reprise du builder (mode Code) et le terminal, où
  le /sys de l'utilisateur disparaissait de la même façon.
- Test : la compaction réactive en plein tour garde le rappel, avec et sans
  PROJ_SNAPSHOT, et ne range aucun système injecté ; échoue sur l'ancien code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:11:59 +02:00
MichaelandClaude Opus 5.5 c888afb493 Contexte : avec PROJ_SNAPSHOT, la date et le dossier de travail passent dans le bloc figé
Le système portait deux valeurs qui bougent : la date du jour — à minuit,
toute discussion en cours recalculait son prompt entier — et le dossier de
travail, propre à chaque discussion, si bien que deux discussions ne
partageaient même pas leur système. Avec PROJ_SNAPSHOT, ces valeurs
rejoignent le bloc projet figé, en tête du premier message ; le système ne
garde que les consignes (« ton dossier de travail est dans ton contexte »).

- Bloc « Environment (from Loki) » : date (avec l'année, et l'année périmée
  que la consigne web nommait), dossier de la discussion, ou poste distant
  ciblé (nom, système, dossier, hors ligne).
- Contenu toujours vivant : relu à chaque tour ; un changement de jour part en
  une ligne de <context_update>, tout autre écart (poste hors ligne, autre
  cible) renvoie le message entier. L'avertissement « hors ligne » ne fige
  donc plus le système.
- Hôte, compte et dossier des scripts, les mêmes pour toutes les discussions,
  restent dans le système.
- Seul l'assemblage d'un tour de discussion avec la clé pose caps.envInCtx :
  tâches planifiées, sous-agents, vérification et terminal gardent leur
  système, date et dossier compris.
- Sans la clé : système identique à l'octet (gabarits relevés sur le code
  d'avant, test). Format d'instantané passé à 2 : les instantanés persistés
  sont repris neufs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:08:07 +02:00
MichaelandClaude Opus 5.5 b7d7bfbe59 Interface : corrections de relecture du lot 2 — « Optimiser » seulement dans l'éditeur du preset en service
La ligne de l'optimiseur s'affichait dans l'éditeur de TOUT preset local, y
compris un preset neuf ou un autre que celui en service, alors que la mesure
porte toujours sur le preset actif (le README le dit) : un clic depuis
l'éditeur d'un autre preset aurait optimisé et proposé d'écrire celui en
service.

- La dernière liste des presets est gardée ; la ligne n'apparaît que si le
  preset ouvert est l'actif, et local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:33:10 +02:00
MichaelandClaude Opus 5.5 568f344273 Raisonnement : corrections de relecture du lot 2 — un refus de gabarit reconnu sans faux positif
Avec REASONING_ECHO, tout 400/500 dont le corps contenait « thinking » passait
pour un refus du raisonnement renvoyé, et ce test passe avant le filet des
outils. Une erreur d'analyse d'appel d'outil cite le texte du modèle : s'il y
écrivait « thinking », le tour était rejoué sans raisonnement et, si la
relance passait par chance, la clé était coupée pour ce modèle jusqu'au
redémarrage.

- « Failed to parse input … » (appel d'outil mal formé) n'est jamais un refus
  du gabarit : le filet des outils s'en charge, comme sans la clé.
- « thinking » ne compte que dans une erreur du gabarit (jinja, template) ;
  raise_exception et les messages invalides restent reconnus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:32:20 +02:00
MichaelandClaude Opus 5.5 5f00b6f041 Contexte : corrections de relecture du lot 2 — seul un bloc posé par Loki est retiré, jamais un texte tapé
Les blocs <context_update> de PROJ_SNAPSHOT étaient reconnus à leur seul
préfixe de texte, et le nettoyage tournait AUSSI sans la clé (requête,
historique persisté, titre, tâche du vérificateur, export, résumé). Un message
de l'utilisateur qui commençait par cette balise — un journal de requête collé
par qui travaille sur Loki — perdait son début en silence : le défaut n'était
plus identique à l'octet.

- Message.CtxUpd marque le message où Loki a posé un bloc (prependCtxUpdate),
  comme ImgRelay pour les relais d'image ; seuls les messages marqués sont
  nettoyés, et la marque tombe avec le bloc.
- La marque ne quitte jamais Loki : retirée à l'envoi avec celle des relais.
- Sans la clé, aucun message n'est marqué : rien n'est retouché.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:31:48 +02:00
MichaelandClaude Opus 5.5 39487f9449 Cache : corrections de relecture du lot 2 — KEEP_TURN_IMAGES retire les images avant tout résumé, partout
Le README promet que les images gardées partent d'abord et que le résumé
(avec perte) ne suit que s'il reste nécessaire. C'était vrai en début de tour
et entre deux étapes, pas ailleurs : la compaction de fin de tour, le filet
réactif (prompt refusé) et la fenêtre pleine en génération retiraient les
images ET résumaient d'un même geste, alors que le retrait seul suffisait.

- Fin de tour : même retrait que le début de tour, puis le seuil est relu.
- Prompt refusé pour débordement : images retirées, requête rejouée sans
  elles, avant la compaction de secours.
- finish=length : images retirées d'abord ; l'étape est rejouée sans résumé
  si le contexte libéré suffit.

Sans la clé, aucune image gardée : chemins d'avant à l'identique.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:30:14 +02:00
MichaelandClaude Opus 5.5 78d15cefbf MoE : corrections de relecture du lot 2 — LOAD_GUARD ne refuse plus sur une borne fausse, et le refus se voit
La garde du mode de chargement est le seul refus actif par défaut du lot 2 :
il ne doit tomber que sur un échec certain, et se dire là où l'on regarde.

- Serveurs --rpc (ou LLAMA_ARG_RPC) : une part des poids part ailleurs, la
  VRAM locale ne borne plus rien — avertissement, jamais de refus.
- Mac Intel : la mémoire n'est unifiée que sur Apple Silicon ; ailleurs la
  VRAM est inconnue, donc jamais de refus (l'agrandissement du cache RAM
  reste coupé sur tout macOS, comme avant).
- LLAMA_ARG_NO_MMAP / LLAMA_ARG_MLOCK restées dans l'environnement ne
  comptent plus sur un moteur à --load-mode, qui les ignore : un lancement en
  mmap n'est plus refusé pour elles.
- Le refus sortait de « loki serve » avant tout chargement : l'interface
  affichait un chargement sans fin. modelLoadError le reconnaît et dit quoi
  faire (--load-mode mmap ou LOAD_GUARD=off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:28:57 +02:00
MichaelandClaude Opus 5.5 f0d5711a1a Slots : corrections de relecture du lot 2 — une lecture d'un client ne prend plus le slot
Les proxys de Loki (/v1 et le relais) comptaient TOUTE requête d'un client
comme une requête qui passe par le slot : un GET /v1/models ou une sonde
/health (Open WebUI en envoie régulièrement) annulait le préchauffage en vol
et retirait le tampon de la discussion. Avec PREWARM, COMPACT_CONTINUATION ou
SLOT_PERSIST, la clé devenait inopérante dès qu'un client sondait le moteur.

- engineProxyBegin : une lecture (GET, HEAD) reste comptée en vol, comme
  avant, mais n'avance pas le numéro des requêtes et garde le préchauffage ;
  une complétion de client garde le comportement d'avant.
- README : avec PREWARM, SLOT_PERSIST ne garde presque rien (le préchauffage
  passe par le slot après chaque tour) — dit plutôt que découvert.

Sans aucune de ces clés, rien ne change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:26:42 +02:00
MichaelandClaude Opus 5.5 a94c752529 Optimiseur : corrections de relecture du lot 2 — verrou périmé reconnu, application interrompue défaite
Le verrou de l'optimiseur ne doit jamais bloquer quand aucune optimisation
ne tourne, et ne jamais passer pour périmé quand elle tourne. Plusieurs
identités de processus ne tenaient pas ces deux promesses hors Windows.

- Linux : l'instant de démarrage est relatif au boot. Après une coupure, un
  service relancé au même moment du démarrage retrouvait PID et top : le
  verrou de l'ancien passait pour vivant et tout était refusé jusqu'à son
  retrait à la main. L'identifiant du boot le préfixe ; un verrou qui porte
  notre PID sans être l'un des nôtres est périmé.
- Linux : un binaire remplacé par une mise à jour (« (deleted) ») faisait
  passer le propriétaire vivant pour mort. La mise à jour est de toute façon
  refusée pendant une optimisation (interface et loki update).
- macOS : ps rend lstart dans la langue et le fuseau de l'appelant ; le web
  (launchd) et un terminal en français ne lisaient pas la même identité. LC_ALL=C
  et TZ=UTC.
- Un verrou illisible tout juste créé (vide entre création et écriture) n'est
  plus retiré : seulement après 10 s.
- loki tune : terminal fermé ou kill (SIGTERM, SIGHUP) annulent proprement,
  comme Ctrl-C ; Ctrl-C retrouve son sens aux questions de fin.
- Application : la sauvegarde est notée dans le verrou ; un Loki tué avant la
  vérification remet l'ancienne version au redémarrage. Deux clics rapprochés
  ne lancent plus deux applications. Un échec de relance du vrai moteur se
  voit dans le résultat, plus seulement au journal ; un essai qui survit à son
  arrêt reste noté dans le verrou.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:25:39 +02:00
MichaelandClaude Opus 5.5 9d02592d4f Optimiseur : relecture — slot fixe sous SIDE_SLOT, moteur mort vu tôt, verrou réécrit sous Windows
- Avec SIDE_SLOT, les tours en profondeur d'un essai restent sur le second
  slot : la reprise du cache ne dépend plus du slot choisi par le moteur
- Après application, un moteur qui meurt au chargement (OOM) ramène l'ancienne
  version en quelques secondes au lieu d'attendre le délai entier
- La réécriture du verrou (essai en cours) réessaie quand Windows refuse de
  remplacer un fichier lu au même instant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:59:50 +02:00
MichaelandClaude Opus 5.5 9bffff78c2 Optimiseur : loki tune et bouton « Optimiser », essais sans perte sur un moteur privé
Chercher à la main le bon micro-lot, les threads ou la marge --fit d'un preset
demandait de dupliquer, basculer et comparer des bench complets un par un.
L'optimiseur le fait sur un moteur d'essai, sans jamais toucher à config.env ni
à ce que calcule le modèle, et n'écrit rien sans un clic.

- Isolation : moteur principal arrêté puis relancé ; chaque essai est une copie
  de la configuration (LOKI_HOME/tune/run), lancée par « loki serve » sur
  127.0.0.1 et un port libre, groupe de processus tué en sortie
- Verrou exclusif inter-processus (LOKI_HOME/tune.lock, PID + démarrage +
  binaire) vérifié par serviceAction start/restart et par le chat, les tâches,
  bash_bg, /v1, le bench, la bascule et l'enregistrement de preset, GPU, clé
  d'API, moteur ; essai orphelin arrêté avant tout démarrage du moteur
- Essais : lots, threads, délestage, marges --fit, files CUDA ; placement --fit
  et SPEC/CUDA_GRAPH_OPT/--backend-sampling seulement sur demande
- EXTRA_ARGS jeton par jeton, liste blanche seulement ; ligne de commande de
  chaque essai composée à blanc et comparée à la référence (« dénature »),
  contexte, slots et cache KV recontrôlés une fois chargé
- Mesure : bench complet du lot 1 à profondeur fixe ; score = durée d'un tour
  type (médianes de la télémétrie) ; gain > max(3 %, écart entre passages)
- Application sur clic : copie du preset, ou preset actuel sauvegardé, réécrit
  clé par clé, vérifié par une sonde et rétabli en cas d'échec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:56:11 +02:00
MichaelandClaude Opus 5.5 f87c7b615b MoE : avis de placement, copie en placement auto et garde du mode de chargement
Un MoE aux experts sur CPU se règle aujourd'hui à la main (-ot, --n-cpu-moe
40, UBATCH 512) sans que Loki dise ce que --fit ferait mieux, ni qu'un
--load-mode none/mlock trop gros pour la RAM mène au swap ou à l'OOM
(llama.cpp #26110). Rien n'est réécrit en douce : des avis, une copie sur
demande, un refus seulement quand l'échec est certain.

- moeNotes (pure, comme nglArgs) : experts placés à la main → --fit ne les
  place pas, et ne pas monter UBATCH seul (tampon plus gros sur chaque carte) ;
  placement auto, MoE plus gros que la VRAM, UBATCH ≤ 512 → « UBATCH 2048+ » ;
  moteur sans --fit → « garde --n-cpu-moe » ; --fit coupé par un autre -ot.
  Le détecteur cpuExperts ne compte un -ot que s'il vise les experts (exps)
  vers le CPU : celui de per_layer_token_embd n'en est pas.
- « Dupliquer en placement auto… » (éditeur, /api/preset/autoplace) : COPIE du
  preset affiché, sans -ot des experts, --n-cpu-moe, --cpu-moe, -ngl,
  --tensor-split, --fit off, -b/-ub ni drapeaux de chargement ; NGL retiré,
  UBATCH 2048, BATCH 4096, --load-mode mmap ; CTX, cache KV, échantillonnage
  intacts. Refusée si --fit resterait inactif ou si CTX vaut 0. L'original
  n'est pas touché, la copie n'est pas activée.
- loadModeRisk : none, mlock, dio, mmap+mlock (et --no-mmap, --mlock,
  LLAMA_ARG_*) face à la RAM effective (limite cgroup comprise), en comptant
  modèle moins VRAM (tout le modèle sur macOS) : avertissement au-delà de
  80 %, refus au lancement au-delà de 90 % de la borne basse seulement ;
  LOAD_GUARD=off le lève. VRAM inconnue : jamais de refus.
- Éditeur : avis sous « Experts MoE sur CPU » et sous « Chargement ».

Ligne de commande et environnement du moteur inchangés (test de
non-régression) ; presets externes ignorés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:12:56 +02:00
MichaelandClaude Opus 5.5 986a636c20 Placement : liaison PCIe des cartes et guide d'ordre dans l'éditeur, en conseil
Deux cartes inégales (5060 Ti + 3060) se placent par deux règles du moteur
qui peuvent s'opposer : --fit remplit d'abord la DERNIÈRE carte, qui porte
la couche de sortie, et les experts MoE en RAM sont recopiés au prefill vers
la PREMIÈRE. Pour choisir l'ordre il faut voir la liaison de chaque carte ;
l'éditeur n'en montrait rien.

- detectGPUs lit aussi la liaison PCIe (génération et largeur, actuelles et
  maximales), le bus et l'horloge mémoire max, dans la même invocation de
  nvidia-smi. « [N/A] » vaut zéro ; un pilote qui refuse un champ fait
  retomber sur la requête historique (jamais de GPU perdu), un délai dépassé
  n'est pas relancé. Borné à 10 s. La largeur du bus mémoire n'existe pas
  dans nvidia-smi : absente.
- /api/backends/devices : une seule lecture nvidia-smi par énumération
  complète mémoire manquante ET liaison (annotateDevices, pure), par nom de
  carte, CUDA seulement, rien pour deux cartes homonymes. La lecture des
  jauges (gpuStatsCached) n'est pas touchée.
- Éditeur : liaison max par carte (l'actuelle et l'horloge en info-bulle),
  guide de placement dans l'ordre du moteur (--device compris), marges
  FIT_TARGET carte par carte, signalement d'un --tensor-split ou d'un
  CUDA_VISIBLE_DEVICES propre au preset, lien « inverser l'ordre » ; l'ordre
  choisi survit aux cases cochées.
- Pas de bouton de mesure : comparer deux ordres demande deux rechargements
  et un ordre inversé peut manquer de VRAM — le guide explique la marche à
  suivre (copie du preset, bench complet sur chacun).
- loki gpu affiche la liaison.

Rien ne change dans la ligne de commande du moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:03:02 +02:00
MichaelandClaude Opus 5.5 889a85888d Persistance : un résultat d'outil supprimé pendant son écriture ne renaît plus
TestToolResultsSaveLoadDelete échouait de temps en temps en suite
complète (1 fois sur ~300 en isolé). Vraie course dans le code, pas dans
le test : deleteToolResultsFor retirait les résultats EN ATTENTE de
l'écrivain différé, mais pas ceux qu'il avait déjà pris dans son lot.
Si la transaction de suppression passait avant celle de l'écrivain, le
résultat était écrit juste après avoir été effacé, et renaissait.

En production, la suppression d'une discussion passe d'abord par
forget + flush, ce qui masquait le problème ; mais forget non plus ne
pouvait rien contre un lot déjà parti.

- l'écrivain note les résultats du lot en vol ; dropToolRes (donc
  forget et deleteToolResultsFor) les marque annulés
- l'écrivain vérifie l'annulation DANS sa transaction ; la suppression
  annule AVANT d'ouvrir la sienne : bbolt sérialisant les deux, soit le
  résultat est sauté, soit il est écrit puis effacé
- annulations oubliées à la fin du lot : un nouveau résultat s'écrit
- test déterministe (écrivain retenu en plein lot) qui échouait avant
  la correction ; suite complète passée 3 fois, TestToolResults ×400

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:50:00 +02:00
MichaelandClaude Opus 5.5 5954d75118 Placement : SPLIT_MODE=tensor, parallélisme de tenseurs entre cartes, en opt-in
En découpe par couches (défaut), chaque carte traverse ses couches à son
tour. En mode tensor, chaque couche est coupée entre les cartes, qui
additionnent ensuite leurs morceaux : décodage plus rapide d'après
l'amont, prefill nettement plus lent. Sur deux GeForce en PCIe et une
boucle d'agent qui relit beaucoup, rien n'est acquis : expérimental,
off par défaut, et une longue liste de refus propres plutôt qu'un
moteur qui meurt en boucle. Sans la clé, ligne et environnement
identiques à l'octet près (test).

- détection : la liste littérale {none,layer,row,tensor} dans l'aide du
  moteur — « tensor » seul y est partout (--tensor-split) ; absente =
  aucun compagnon posé, note au journal
- GGML_CUDA_ALLREDUCE=internal sauf choix de l'environnement : NCCL
  compresse en BF16 les réductions du prefill (avec perte) ; nccl
  gardé s'il est posé, avec un avertissement ; note NCCL indisponible /
  P2P à poser soi-même
- -ngl all, -ts au prorata de la VRAM totale (--list-devices, ordre du
  moteur, --device compris), -fa on : chacun seulement si EXTRA_ARGS ou
  une LLAMA_ARG_* ne l'a pas déjà ; CTX explicite exigé
- refus : KV autre que f16/bf16/f32 (KV_TYPE*, -ctk/-ctv, LLAMA_ARG_*,
  jamais réécrit), --backend-sampling, -fa off, -sm à la main, poids ou
  KV sur CPU, NGL partiel, SPEC, SIDE_SLOT, une seule carte ou non CUDA,
  architectures exclues par llama.cpp et qwen4exp, preset externe
- pas de --fit en mode tensor : estimation poids + cache de CTX jetons
  contre 90 % de la VRAM, refus au-delà, CTX jamais réduit
- files CUDA, CUDA_GRAPH_OPT et FIT_TARGET voient le -sm tensor ;
  SLOT_PERSIST refusé avec la clé ; diagnostic du journal pour les
  échecs propres au mode tensor
- README et configTemplate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:35:16 +02:00
MichaelandClaude Opus 5.5 539e36ea49 Spéculation : SPEC=ngram et mtp+ngram, n-grammes du contexte en opt-in
En mode Code, le modèle recopie sans cesse ce qui est déjà dans le
contexte : chemins, diffs, arguments JSON, fichiers réécrits. ngram-mod
de llama.cpp y cherche la suite des 24 derniers jetons et propose d'un
coup les 48 à 64 suivants, sans modèle brouillon. La clé SPEC (off par
défaut, inchangé) gagne deux valeurs : ngram, et mtp+ngram qui y ajoute
la tête MTP. Même vérification exacte que MTP : distribution inchangée,
mais pas le même texte au bit près (vérification par lots), donc on
compare des sessions rejouées, pas des diffs.

- forme explicite seulement : --spec-type ngram-mod
  --spec-ngram-mod-n-match 24 --spec-ngram-mod-n-min 48
  --spec-ngram-mod-n-max 64 ; jamais --spec-default (TODO amont, contenu
  susceptible de changer avec le moteur)
- garde-fou d'aide sur « --spec-ngram-mod-n-match » (pas le simple mot
  ngram-mod, encore listé par les moteurs d'avant le renommage) ; absent =
  SPEC ignoré, note au journal
- mtp+ngram : --spec-type draft-mtp,ngram-mod seulement avec draft-mtp
  dans l'aide ET une tête MTP réelle (tenseur nextn ou MODEL_DRAFT) ;
  sinon n-grammes seuls, avec la raison — jamais « failed to create MTP
  context » en boucle
- EXTRA_ARGS/LLAMA_ARG_* : mêmes exclusions qu'avant, plus --spec_type et
  tout --spec-ngram-* (--spec-type s'additionne sans prévenir) ; pour
  ngram seul, un --spec-draft-* fait aussi taire Loki
- poids sur CPU (experts MoE, -ot, NGL partiel) : refusé tant que
  GGML_OP_OFFLOAD_MIN_BATCH (env ou OP_OFFLOAD_MIN_BATCH) ne dépasse pas
  le lot de vérification de 65 jetons, avec la suggestion
  OP_OFFLOAD_MIN_BATCH=128 ; avertissements pour un modèle plus gros que
  la RAM, les points de reprise d'un hybride, NGL imposé (logits)
- avec ngram, une tête MTP ou MODEL_DRAFT inutilisés sont signalés
- --spec-synth-len/--spec-synth-rates (et LLAMA_ARG_SPEC_SYNTH_LEN) :
  acceptation au hasard, avertissement au lancement ; Loki ne les pose
  jamais
- télémétrie : /api/perf/summary donne draft_n, draft_accepted et
  draft_rate par nature de requête
- UI : deux options du sélecteur de décodage spéculatif ; README et
  configTemplate
- tests : forme explicite, garde d'aide, repli de mtp+ngram, exclusions,
  seuil d'offload, ligne inchangée sans SPEC, acceptation par nature

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:28:25 +02:00
MichaelandClaude Opus 5.5 28ba44eb46 Slots : SLOT_PERSIST garde l'état de la discussion à la bascule de preset, en opt-in
Passer de A à B puis revenir à A relance llama-server deux fois, et la
conversation de A est recalculée en entier au retour. Avec SLOT_PERSIST=on
(off par défaut), Loki demande au moteur d'écrire l'état du slot 0 juste
avant la bascule, et le recharge au retour avant le premier message de la
discussion — seulement si tout concorde, sinon calcul normal, en silence.

llama.cpp ne vérifie à la relecture que types et tailles : rien ne dit quel
build ni quels réglages ont calculé ce cache. La clé de validité est donc
l'empreinte de tout ce qui touche au calcul, prise par « loki serve » au
lancement : ligne de commande complète (modèle, CTX, types KV, NGL, lots,
gabarit, SPEC, EXTRA_ARGS…), LLAMA_ARG_*/GGML_*/CUDA_*, taille et date du
modèle et de ses tranches, du projecteur, du brouillon, du binaire et de
ses bibliothèques. Pas de cas « mise à jour du moteur » : elle change la clé.

- --slot-save-path posé aussi pour SLOT_PERSIST accepté, sous les mêmes
  conditions de sécurité que l'isolation du lot 1 (boucle locale ou clé
  d'API) ; refusé avec le décodage spéculatif (le save ne garde pas le
  brouillon ; /slots speculative revérifié à chaque fois), des poids sur
  CPU, un --slot-save-path à la main ; la ligne reste alors celle d'avant
- sauvegarde depuis l'interface ou une tâche, avant l'arrêt : le slot doit
  porter un tour de la discussion (tampon d'engineMarkMain), ≥ 4096 jetons,
  ≤ 8 Gio estimés, double de place libre ; nom provisoire, renommé
  seulement si aucune requête n'est partie pendant l'écriture
- rechargement avant la première requête de la discussion (ou son
  préchauffage) : même clé de moteur, même discussion, slot 0 vierge (/slots
  sans id_task) ; une seule tentative, fichier retiré ; toute erreur =
  calcul normal
- deux fichiers au plus ; clé retirée = tout effacé au lancement suivant ;
  SLOT_PERSIST préservé à la bascule comme HOST (sinon B effaçait l'état de A)
- README : SIDE_SLOT, le cache KV plein ne déclenche jamais de compaction
- tests : plan et refus, ligne inchangée, empreinte (clé d'API exclue,
  fichiers et réglages inclus), rotation, séquence save puis restore sur un
  faux moteur, refus à la sauvegarde et au rechargement, autre clé, défaut
  sans aucun appel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:17:08 +02:00
MichaelandClaude Opus 5.5 7a57322a62 Slots : SIDE_SLOT ouvre un second slot pour les travaux annexes, en opt-in
En --parallel 1, une vérification, un sous-agent, une tâche ou un bench
prennent le slot de la discussion : son état part dans le cache RAM et en
revient, ou se recalcule s'il n'y tient plus. Avec SIDE_SLOT=on (off par
défaut), le moteur ouvre deux slots et la discussion garde le sien.

Dimensionnement choisi pour qu'un débordement concurrent soit impossible par
construction : cache KV NON unifié (--no-kv-unified) et -c 2×CTX, soit deux
flux séparés de CTX jetons. Sous cache unifié, les deux slots partagent le
pool et, plein, llama.cpp renvoie « Context size has been exceeded. » aux
deux — que Loki prenait pour un débordement de la conversation. Chaque slot
garde la fenêtre entière : ctxWindow et la compaction restent sur CTX, rien
n'est raccourci. --cache-idle-slots ne vide les slots au repos que sous cache
unifié : rien à régler.

- lancement : refus (un slot, ligne d'avant, note au journal) si modèle + deux
  états dépassent 90 % de la VRAM, VRAM ou GGUF inconnus, poids sur CPU,
  moteur sans --no-kv-unified, PARALLEL≠2, ou -c/-np/-kvu/--no-kv-unified/
  --kv-unified-per-slot/--cache-idle-slots dans EXTRA_ARGS (ou leurs
  LLAMA_ARG_*) ; note de VRAM en plus, avertissement si NGL imposé
- routage id_slot seulement si /props annonce 2 slots d'au moins CTX jetons :
  0 pour le tour, ses étapes, le préchauffage et le résumé en continuation ;
  1 pour vérification, sous-agents, tâches, bench, résumé sur transcription
- clients /v1 (proxy et relais) : corps réécrit en id_slot 1, quel qu'il soit
- cohérence lot 1 : l'effacement de slot s'abstient (2 slots) ; une requête
  du slot 1 n'avance pas le numéro d'engineSlotHolds, la continuation de
  compaction reste possible après une vérification
- « Context size has been exceeded. » sans nombre de jetons, deux slots en
  service : requête rejouée telle quelle, jamais compaction ni réduction
- préchauffage permis sur les deux slots de SIDE_SLOT, vers le slot 0
- éditeur de preset : VRAM du second slot ou raison du refus
- tests : ligne identique sans la clé, chaque conflit refuse sans changer la
  ligne, routage par nature, aucun id_slot sur un slot / preset externe /
  fenêtre partagée, rejeu sans compaction, proxy forcé, préchauffage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:02:40 +02:00
MichaelandClaude Opus 5.5 c1d3757f23 Cache : KEEP_TURN_IMAGES, relecture — budget plein vérifié avant de ranger l'image
Le relais rangeait l'image sur disque avant de regarder si le budget des
images gardées était déjà plein : un fichier écrit pour rien, élagué 24 h
plus tard.

- budget vérifié d'abord ; plein, l'image reste éphémère sans être rangée
- README : sur un modèle hybride, le gain dépend des points de reprise du
  moteur après une image, à vérifier dans la télémétrie avant d'adopter la clé
- test : budget plein, rien d'écrit dans chatimg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:49:06 +02:00
MichaelandClaude Opus 5.5 f74f64ed90 Cache : KEEP_TURN_IMAGES garde les images d'outils et NUDGE_IN_TOOL range le rappel de budget, en opt-in
Une capture ou une image vue par see_image ne vivait que le temps du tour :
au tour suivant elle manquait à l'historique, le préfixe en cache divergeait
à elle et toute la boucle d'outils qui suivait était recalculée. Sur Qwen3.5,
le rappel de budget en message user faisait de même pour le tour en cours :
le nouveau message devient la « dernière question » et tout le tour est rendu
autrement. Deux clés, off par défaut et étiquetées zone grise ; sans elles,
requêtes identiques à l'octet près (testé).

- KEEP_TURN_IMAGES : image gardée par référence (chatimg, chiffrée si la
  mémoire l'est), relue à l'identique avant d'être gardée, vision active
  revérifiée à chaque tour ; preset externe seulement s'il déclare la vision
- coût mesuré par le moteur (écart de prompt_tokens moins le texte ajouté) ;
  image non mesurable = éphémère comme avant ; total sous 10 % de la fenêtre,
  la première qui dépasse et les suivantes du tour restent éphémères
- compaction, réduction forcée et début ou milieu de tour au seuil retirent
  ces images d'abord (légende et imageLostMarker gardés), le résumé ne suit
  que s'il reste nécessaire ; clé retirée ou vision perdue : retirées au tour
- relais marqué ImgRelay (persisté, retiré à l'envoi) : ni demande réinjectée
  ni preuve de demande servie, fil rouvert compris ; une image non gardée
  n'entre plus dans l'historique par une compaction en cours de tour
- discussion seulement (ni tâche, ni sous-agent, ni vérification) ; fichier
  d'image rafraîchi à chaque usage, l'élagage de 24 h ne le prend pas
- NUDGE_IN_TOOL : rappel de budget au bout du dernier résultat d'outil, même
  message persisté ; moteur local, sonde de gabarit « préfixe instable » pour
  ce modèle ; sinon, ou forme inattendue, message à part comme avant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:46:01 +02:00
MichaelandClaude Opus 5.5 b0c53e32af Compaction : COMPACT_CONTINUATION, relecture — un refus mémorisé ne vaut que pour le même fil
Le refus mémorisé ne regardait que la taille du contexte. Il n'est évalué
qu'au-dessus du seuil : une discussion vidée, éditée ou régénérée qui
remontait dans la même plage de jetons voyait sa compaction sautée sur la foi
d'un refus qui concernait un autre fil.

- le refus garde l'empreinte de l'historique (celle de perfPrefix) ; seul un
  historique qui prolonge celui d'alors en profite, tout autre retente
- testé : historique modifié ou raccourci, refus noté par une vraie
  compaction, filet réactif jamais bloqué

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:25:35 +02:00
MichaelandClaude Opus 5.5 2097c9f915 Compaction : COMPACT_CONTINUATION résume dans le prolongement du prompt en cache, en opt-in
Le résumé d'une compaction part aujourd'hui dans une requête à part — prompt
du résumeur et transcription des anciens tours — que le moteur local calcule à
froid : des dizaines de secondes sur un 27B, des minutes sur un MoE, à chaque
compaction. Nouvelle clé COMPACT_CONTINUATION (off par défaut) : quand le slot
porte encore le prompt de la discussion, la requête de résumé est celle du tour
telle qu'elle est partie, suivie d'une seule demande de résumé ; le moteur ne
calcule qu'elle. Sans la clé, requêtes de compaction identiques à l'octet près
(prompt du résumeur comparé à une copie figée, testé).

- vue MODÈLE pour tout ce qui est rangé : bornes, archives recall, demande
  réinjectée, garantie de réduction et sortie ne changent pas ; la vue
  d'envoi (turnViewDry, wireMessages, buildChatPayload) ne sert qu'à la
  requête, rien d'injecté n'entre dans l'historique (testé)
- frontière recalée sur la vue envoyée (messages non système un pour un,
  vérifiés rôle par rôle), désignée par le nombre de messages gardés et le
  début du premier ; tout résumer avant, dater l'avancement à la frontière
- mêmes règles de résumé (+ mode Code), même budget, température 0.2 sans
  l'échantillonnage du preset, enable_thinking=false ajouté aux arguments du
  tour, reasoning_effort du tour, tool_choice « none », sans flux
- messages utilisateur de fin hors de la vue : pas en cache, et pas deux
  `user` d'affilée pour les gabarits stricts
- slot vérifié : tampon posé par une étape de tour acceptée (llm_slots.go,
  sous le verrou du compteur en vol), perdu dès qu'une autre requête part —
  vérification, sous-agent, tâche, préchauffage, résumé, bench, /v1 — ou que
  le modèle ou la fenêtre changent ; relu à l'envoi
- marge en jetons réels (dernier compte + non vu + demande + budget + 5 %)
- repli sur la transcription au moindre écart : refus du moteur, réseau,
  appel d'outil émis (tool_calls décodés) ou écrit en texte, raisonnement
  seul, résumé vide ; refus du gabarit ou appel d'outil = suspendue pour le
  modèle jusqu'au redémarrage, deux échecs de suite aussi
- avec la clé : pas de résumé quand même un vide ne réduirait pas de 20 %
  (borne exacte, avant tout archivage) ; après un refus faute de réduction,
  pas de nouvel essai avant +10 % de contexte, jamais à 90 % de la fenêtre,
  oublié quand le contexte baisse
- réactif, fenêtre pleine, bouton manuel, tâches, sous-agents, terminal,
  preset externe : chemin d'avant ; télémétrie kind=compact avec la discussion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:21:57 +02:00
MichaelandClaude Opus 5.5 eb58f7982f Cache : PREWARM, relecture — commentaire du projet forcé rendu à son appel
Le différé du préchauffage s'était glissé entre le commentaire du projet forcé
et setProjectOverride : il passe au-dessus, avec le sien.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:04:23 +02:00
MichaelandClaude Opus 5.5 630e84cc08 Cache : PREWARM prépare le prochain tour pendant que l'utilisateur lit, en opt-in
Une compaction, un dernier message rendu autrement au tour suivant ou une
tâche planifiée qui a pris le slot laissent au message suivant un recalcul
inévitable : des secondes sur un 27B, des dizaines sur un MoE aux experts en
RAM, avant le premier mot. Nouvelle clé PREWARM (off par défaut) : dès que le
moteur local est libre, Loki lui envoie la requête du prochain tour suivie
d'un message utilisateur « . », max_tokens 1, sans flux. Le moteur calcule le
préfixe et pose un point de reprise au début de ce message ; le vrai tour ne
calcule plus que le sien. Sans la clé, aucune requête de plus (testé).

- assemblage partagé, contenu vivant : turnViewDry (même code que turnView,
  sans rien toucher à la discussion, décision PROJ_SNAPSHOT via
  projSnapDecide), wireMessages, turnReasoning et buildChatPayload, extraits
  de runChat ; corps identique à l'octet près à celui d'avant (matrice
  d'options et de modes, testée contre une copie de l'ancien code)
- jamais devant un vrai travail : toute requête de Loki l'annule au départ
  (compteur de llm_slots.go, sous son verrou), sauf un tour de chat dont les
  messages sérialisés, outils et arguments du gabarit prolongent exactement
  le préfixe préchauffé
- moteur local, un seul slot (/props total_slots), pas pendant un tour, une
  tâche, un bench, un travail annexe ni une requête en vol ; un seul à la
  fois ; pas si le prochain tour compactera, ni à moins de 10 min de minuit
- requête brute : ni compaction, ni relance, ni effort appris, ni CtxUsed,
  ni stats ; erreurs lâchées (une ligne de journal par statut) ; rien persisté
- déclencheurs : fin de tour sans file d'attente, fin de tâche (projet forcé
  levé, slot effacé) ; full = aussi changement de discussion après 3 s
- capacités du dernier tour gardées en mémoire par discussion ; mode Code =
  rôle d'un message ordinaire, une demande de plan diverge et annule
- télémétrie kind=prewarm ; la vérification du cache RAM après une tâche se
  fait sur lui

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:01:57 +02:00
MichaelandClaude Opus 5.5 4932999d5c Contexte : PROJ_SNAPSHOT fige le bloc projet par discussion, ses changements arrivent en mise à jour, en opt-in
Le contexte du projet (description, index mémoire, trackers, AGENTS.md) part
en tête du premier message utilisateur, reconstruit à chaque tour : une page
créée ou une valeur de tracker notée faisait recalculer toute la conversation
derrière lui, 10 à 45k tokens. Nouvelle clé PROJ_SNAPSHOT (off par défaut) :
avec on, chaque discussion garde une copie datée du bloc, renvoyée à l'octet
près, et les changements arrivent en tête du message suivant dans un bloc
<context_update from="loki">, rangé dans l'historique. Sans la clé, la requête
est identique à l'octet près (testé contre l'ancien assemblage).

- en-têtes figés « as of <date> » pour l'index mémoire et les trackers, sans
  « answer straight from this » ; ligne fixe du système, seulement avec la clé
- mises à jour par type : +/~/- par page (clé « ](fichier) »), une ligne
  complète par tracker (clé = slug), texte COMPLET pour la description,
  AGENTS.md, ou un index dont la prose a changé — jamais de diff de prose
- état annoncé structuré et persisté (ProjSnap, omitempty), instantané pris
  sur marqueur explicite : la première page d'un projet vide arrive en mise
  à jour, le premier message ne bouge pas
- rafraîchi (blocs retirés de tout l'historique) quand le prompt change de
  toute façon : compaction début/fin/manuelle, compaction ou réduction en
  cours de tour (bloc vivant aussitôt, rangé comme instantané : pas de
  recalcul de plus), système ou outils modifiés, redémarrage de Loki, réglage
  moteur ou modèle, projet, nom, mode mémoire, mode Code, dépôt, textes des
  en-têtes ; et au-delà d'un seuil de mises à jour accumulées
- mise à jour posée sous c.mu avec l'epoch, après la compaction de début de
  tour ; texte ou multimodal ; retirée du titre, de l'export JSON, de l'entrée
  du résumeur et de la tâche du vérificateur
- loadFrom, Reset et le chargement remettent l'instantané à zéro ; clé retirée
  = nettoyage ; agent off = rien du projet ; tâches et presets externes
  inchangés ; un sous-agent ne voit pas le bloc du tour
- ordre des trackers déjà déterministe (lot 1, test existant)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:44:46 +02:00
MichaelandClaude Opus 5.5 7c9aa04fd9 Raisonnement : REASONING_ECHO renvoie au moteur local la réflexion du modèle, en opt-in
Les gabarits Qwen3.5/3.6 rendent <think>…</think> pour chaque message
assistant après la dernière question : c'est leur format entraîné. Loki ne
gardait que le texte et les appels, donc à chaque étape d'une boucle d'outils
le modèle relisait ses étapes précédentes avec des blocs vides, et le moteur
recalculait le dernier message. Nouvelle clé REASONING_ECHO (off par défaut) :
avec on, le raisonnement séparé par le serveur est gardé avec chaque message
et renvoyé au même modèle. Sans la clé, rien n'est capturé ni envoyé : la
requête est identique à l'octet près (testé).

- Message.ReasoningContent (reasoning_content) + ReasoningModel, persistés ;
  seulement depuis reasoning_content du flux, jamais depuis le découpage
  « </think> » fait chez nous (rendu en double)
- un seul point de sortie (echoMessages) : étiquette toujours retirée,
  raisonnement retiré pour une API externe, un autre modèle, un message sans
  texte ni appel, ou si la sonde dit que le gabarit ne le rend jamais
- comptage : estimateTokens/compactBounds comptent ce qui part et que le
  gabarit rend (passé seulement si la sonde dit qu'il le garde, ou l'ignore) ;
  ctxAfter ne retire plus un raisonnement renvoyé
- débordement : relance d'abord sans raisonnement, avant toute compaction ;
  shrinkToFit retire le raisonnement le plus ancien avant tout le reste ;
  le torse compacté le perd comme le résumé
- erreur de gabarit (raise_exception, thinking, Failed to parse messages) :
  relance une fois sans raisonnement avant tool_choice none / outils coupés ;
  retenu pour le modèle si la relance passe
- runBuilderTurn applique enfin NewHistory comme generate (compaction perdue
  pendant une passe de correction) ; forwardStream affiche la bannière
- sonde de gabarit : nouveau verdict renders_reasoning
- REASONING_PRESERVE (lot 1) inchangée, son interaction documentée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:26:04 +02:00
MichaelandClaude Opus 5.5 3af481ff05 Télémétrie : une sonde du gabarit de chat, diagnostique et sans risque pour le cache
Les pistes de réutilisation du cache dépendent de deux faits que rien ne
mesurait : le gabarit du modèle rend-il encore le raisonnement d'un tour
passé, et le rendu d'un tour d'outil reste-t-il un préfixe exact quand un
message utilisateur s'y ajoute ? La sonde pose ces deux questions au moteur
local par POST /apply-template — un rendu sans état, qui ne prend ni slot ni
place dans la file — et n'en tire que des verdicts oui/non/inconnu.

Elle ne touche à aucune construction de prompt : Loki ne renvoie toujours
aucun reasoning_content au modèle. Toute évolution qui voudrait s'en servir
passera sa propre revue, et lira « unknown » comme « ne rien changer ».

- moteur local seulement ; preset externe : aucune requête, rien d'affiché
- jamais de complétion de repli : avec un slot, elle évincerait le cache
- en tâche de fond après une complétion complète du fil principal, /health
  à 200, au plus une vérification par minute ; authHeader ; 3 s par requête
- mêmes outils, chat_template_kwargs et reasoning_effort que la complétion
- add_generation_prompt=false demandé au moteur ; un moteur qui l'ignore
  donne « inconnu » plutôt qu'un faux « instable »
- tour d'outil bien formé (identifiant de 9 alphanumériques, résultat lié)
- 404, 401, exception du gabarit, délai : inconnu ; 503 relancé
- cache par build_info + modèle + empreinte du gabarit + forme de requête
- verdict dans /api/perf/summary (champ template) et une ligne [tplprobe]
  au journal par verdict nouveau

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:10:42 +02:00
MichaelandClaude Opus 5.5 09ee04037b Bench : corrections de relecture — niveau de raisonnement, préchauffage borné, verrou fiable
La relecture du bench en tâche de fond a trouvé quatre défauts qui le
faisaient échouer ou s'interrompre là où il n'aurait pas dû, et une
course entre la fin annoncée et le verrou rendu.

- Niveau de raisonnement refusé par le gabarit (Qwen3.8 et « high ») :
  le bench rejoue une fois avec le niveau accepté, comme runChatTools,
  et retient la traduction. Il échouait sur ce 500 dans un processus
  neuf. L'erreur HTTP garde le corps entier : les niveaux acceptés sont
  cités au-delà des 300 caractères affichés.
- Préchauffage borné au quart du contexte : -ub 4096 sur 8k de contexte
  envoyait un prompt plus grand que la fenêtre.
- Changer de discussion n'annule plus le bench, et ne lui reprend pas
  le verrou. La libération suit le drapeau benching au lieu de l'epoch,
  qu'une bascule de fil bumpe. Reset arrête toujours le bench et rend
  le verrou.
- Le verrou du chat est rendu avant que le statut ne dise « terminé » :
  un message envoyé aussitôt n'est plus refusé (et le test n'est plus
  instable).
- Indication « possible thrash » mesurée sur les tours seulement, pas
  sur le prefill à froid qui lit légitimement les pages du modèle.
- Interface : agrégats absents (omitempty) affichés à 0 au lieu de
  casser le rendu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:09:01 +02:00
MichaelandClaude Opus 5.5 cf83f5f629 Bench : une mesure honnête à profondeur réelle, en tâche de fond
L'ancien bench ne lisait pas le statut HTTP, inventait un partage 15/85
du temps quand les timings manquaient (et l'enregistrait comme mesuré),
tuilait un petit corpus qu'un brouillon recopiait, et ne disait rien du
prefill à 30k ni du chemin où le cache est repris. Synchrone, il était
coupé par les proxys (60-100 s) pendant que le moteur continuait, et un
simple GET suffisait à le lancer.

- Tâche de fond : POST /api/bench (202, 405 sur GET, 409 si occupé),
  /api/bench/status pour la progression, /api/bench/cancel ; chaque
  requête au moteur porte le contexte annulable.
- Verrou de génération tenu pendant toute la mesure : chat, tâches et
  compaction reçoivent un refus clair ; les clients /v1 un 503 avec
  Retry-After ; /slots occupé (client externe, CLI) refuse aussi.
- Mode rapide par défaut : préchauffage jeté + la ligne courte, sur le
  chat avec gabarit, raisonnement et échantillonnage du preset, seed fixe.
- Mode complet (bouton « bench complet », loki bench --full) : prefill à
  froid à D = min(CTX/2, 32k, ce qui laisse tenir les tours), 16k si des
  poids tournent sur CPU, puis 3 tours user → assistant → user de code
  jamais vu. Reprise du cache lue dans cache_n, jamais supposée ; note
  pour les hybrides (points de contrôle). Pas d'ignore_eos.
- Rien n'est enregistré sans réponses 200 et timings réels, ni pour un
  résultat partiel (budget de 6 min par requête, contexte plein).
- Empreinte enregistrée (configuration + CUDA_VISIBLE_DEVICES + protocole) ;
  les anciennes mesures retombent sur le nom du modèle. Types de cache KV
  et build du moteur affichés, KV quantifié signalé (zone grise).
- Linux : indication « possible thrash » (read_bytes, VmRSS), jamais
  enregistrée.
- Effacement du slot en fin de bench inchangé (engineSideJob, différé :
  il tourne aussi sur erreur et annulation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:52:54 +02:00
MichaelandClaude Opus 5.5 9f6bd725e6 Stockage : corrections de relecture — le remède Unraid décrit tel qu'il existe
« Exclusive access » n'est pas un réglage qu'on passe à Yes sur la page du
partage : c'est un état, que Unraid 6.12 accorde quand « Permit exclusive
shares » est activé et que le partage vit tout entier sur un pool, sans
stockage secondaire (/mnt/user/<partage> devient un lien vers le pool). La
consigne précédente envoyait chercher une option introuvable.
- conseil, compose et README : le vrai chemin (Global Share Settings,
  mover vers le pool puis Secondary = None, « Exclusive access : Yes »),
  et redémarrer le conteneur, Docker ne résolvant le lien qu'au démarrage ;
- un seul chemin par montage fuse.shfs : /data/models répétait /data ;
- l'encart de l'UI, permanent tant que la cause dure, devient masquable
  (localStorage, pour ce texte seulement — un autre conseil réapparaît).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:35:38 +02:00
MichaelandClaude Opus 5.5 056f307983 Stockage : Loki signale un /data ou /models servi par le FUSE d'Unraid
Sur Unraid, /mnt/user/… n'est pas un disque mais shfs, un démon FUSE :
chaque fsync de loki.db et chaque page d'un gros MoE mmappé relue depuis
le disque (modèle plus gros que la RAM) le traverse, à chaque token. Rien
n'est perdu ni altéré, mais le décodage ralentit — sans que rien ne le
dise.

Loki ne déplace rien : changer le chemin hôte d'une installation donnerait
un /data vide. Il le voit et le dit, sur un ton d'information :
- sys_storagefuse.go lit /proc/self/mountinfo (type lu après le séparateur
  « - », montage le plus long qui contient le chemin) pour LOKI_HOME et
  chaque dossier de modèles ; Linux en conteneur seulement, mis en cache
  une minute puisque /api/status est interrogé en boucle ;
- conseil en jaune au démarrage et champ « hint » de /api/status, affiché
  en encart discret, distinct du bandeau rouge de perte de données ;
- compose Unraid et README : « Exclusive access » recommandé (même chemin,
  FUSE court-circuité), /mnt/<pool> en option avancée avec migration
  manuelle et mise en garde sur un pool inexistant (RAM).

La détection de thrash du cache de pages est laissée de côté : elle exige
un signal majflt calibré par modèle sur le serveur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:31:59 +02:00
MichaelandClaude Opus 5.5 667e340e57 Persistance : corrections de relecture — un résultat d'outil ne forge plus de discussion
Relecture de l'écrivain différé (chat_persist.go) : l'ordre, la suppression
et les bascules tiennent ; trois écarts de comportement corrigés.

- saveToolResult et la télémétrie de generate lisaient la clé brute de la
  discussion active ; passés à convEnsureActive, ils pouvaient en CRÉER une
  (tâche de fond sur base neuve → discussion vide dans la liste). Nouveau
  convActiveID : cache chaud, sinon la clé en base, jamais de création.
- Un résultat d'outil confié après la suppression de sa discussion restait en
  mémoire pour toujours (l'écrivain l'écarte, la copie ne partait plus) : il
  n'est plus retenu.
- Vidage de l'écrivain avant « verrouiller la mémoire » (sinon l'envoi tout
  juste fait ne touchait le disque qu'au déverrouillage) et avant le restart
  systemd après MAJ (SIGTERM ne laisse rien finir).

Tests : résultat d'une discussion supprimée ni écrit ni retenu ; base neuve,
saveToolResult reste sous « nosession » sans créer de discussion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:27:54 +02:00
MichaelandClaude Opus 5.5 c3005f5b56 Persistance : le fsync de la discussion ne retarde plus le premier token
StartTurn persistait AVANT de lancer la génération : un marshal, puis deux
commits bbolt (contenu, puis index), soit deux fsync et plusieurs ouvertures
de base — 26-30 ms mesurés sur un NVMe Windows, bien plus sur un /mnt/user
d'Unraid ou un disque de parité, payés à chaque message avant que la requête
ne parte vers llama-server. Même facture au milieu d'un tour (vérification du
mode Code) et pour chaque résultat d'outil coupé (« voir plus »).

- chat_persist.go : un écrivain unique et ordonné. L'instantané reste pris
  sous c.mu (images → références, marshal, identifiant de la discussion) ;
  seule l'E/S part en différé. Le dernier instantané de chaque discussion
  l'emporte (numéro pris sous c.mu), les lots sont fusionnés en UNE
  transaction : contenu + index ensemble, résultats d'outils compris.
- Seuls StartTurn, la persistance en cours de tour et les résultats d'outils
  sont asynchrones. Fin de tour, reset, bascules, compactage manuel restent
  synchrones et attendent tout ce qui précède ; la suppression écarte puis
  attend les écritures qui la visent (plus de discussion ressuscitée).
- Discussion active gardée en RAM, créée sous verrou (plus de double
  identifiant sur base neuve) et changée sous c.mu avec le contenu : un
  instantané ne peut plus écrire l'ancien fil sous le nouvel identifiant.
- « Voir plus » servi depuis la mémoire tant que le résultat n'est pas écrit ;
  verrouillage vérifié avant de rendre un id.
- En plein tour, la base reçoit le journal sous sa forme de fin de tour
  (compactLog, version pure de compactLogLocked) ; la mémoire n'est pas touchée.
- Vidage de l'écrivain avant redémarrage, « Quitter », exec et (dé)chiffrement.

Le contexte vu par le modèle ne change pas : il lit c.Messages en mémoire, les
résultats complets d'outils et la compaction du journal ne servent qu'à l'UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:19:03 +02:00
MichaelandClaude Opus 5.5 9b2e974942 Moniteur : corrections de relecture — un nvidia-smi pendu ne fige plus les jauges
gpuStatsCached tient son verrou pendant la requête : c'est ce qui fait
partager une seule lecture aux onglets. Mais la requête n'était pas bornée,
et le code le dit ailleurs (nvidiaGPUCount) : un pilote coincé peut faire
pendre nvidia-smi. Avant le cache, chaque nouvelle requête relançait son
propre nvidia-smi et les jauges revenaient avec le pilote ; avec le verrou,
un seul processus pendu bloquait /api/vram pour tous les onglets jusqu'au
redémarrage de Loki.

- nvidiaSmiQuery : CommandContext borné à 10 s (large pour une première
  lecture lente sans mode persistant), WaitDelay d'une seconde. Au-delà,
  une erreur ordinaire, retentée au délai normal du cache.
- Le déchargement profite de la même borne : une lecture pendue devient une
  mesure ratée, que gpuSettle sait déjà sauter.
- Test : la vraie requête, binaire absent du PATH, rend bien
  exec.ErrNotFound — la seule erreur que le cache garde une minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:01:11 +02:00
MichaelandClaude Opus 5.5 4f34694400 Moniteur : un seul nvidia-smi pour tous les onglets, aucun pour un onglet caché
Chaque onglet ouvert (et chaque téléphone) interrogeait /api/vram toutes les
3 s, et chaque requête lançait son propre nvidia-smi — y compris depuis un
onglet en arrière-plan que personne ne regarde, pendant que llama-server
génère. Rien de tout cela ne touche au modèle : seules les jauges sont
concernées.

- gpuStatsCached : lecture partagée de 2 s, verrou tenu pendant la requête
  pour que des appels simultanés attendent la même lecture. nvidia-smi
  introuvable (Mac, CPU seul) mémorisé une minute ; une autre erreur se
  retente au délai normal.
- Seul handleVram passe par le cache. Le déchargement (lecture « avant » et
  gpuSettle) garde des lectures fraîches : deux valeurs égales servies par
  le cache feraient conclure gpuSettle à tort. Le cache est vidé après un
  déchargement.
- UI : statut, VRAM et RAM ne sont plus interrogés dans un onglet caché ;
  lecture immédiate au retour.
- Tests : cache (réutilisation, erreur, binaire absent, appels simultanés)
  et échantillonneur du déchargement qui contourne le cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:57:55 +02:00
MichaelandClaude Opus 5.5 3dff7e0e04 Flux : corrections de relecture — le « +N » d'une écriture ne recompte plus tout le corps
Chaque événement de frappe (un par ligne) appelait bodyLineCount sur le corps
ENTIER pour remplir body_lines : un reste de coût quadratique, léger mais
inutile, puisque argPreview tient déjà le compte des sauts de ligne.

- argPreview.lineCount : même règle que bodyLineCount (saut de ligne final
  sans ligne de plus), en temps constant.
- Le test de décodage par morceaux vérifie l'égalité avec bodyLineCount à
  chaque préfixe (fuzz compris).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:54:19 +02:00
MichaelandClaude Opus 5.5 f1f6cbe4ea Flux : l'écriture d'un gros fichier ne coûte plus un temps quadratique côté Go
Pendant qu'un modèle tapait un write de 120 Ko, chaque morceau faisait relire
et redécoder tout le JSON des arguments, puis republiait le corps ENTIER à
chaque ligne : 250 Mo de flux SSE, autant de tas vivant dans le journal, et
près de 8 s de CPU volées au décodage quand des experts tournent sur le
processeur. Même travers pour la détection des appels d'outils écrits en
texte, qui repassait toutes les regex sur toute la réponse à chaque jeton.
Rien de ce que voit le modèle ne change : les arguments exécutés restent
entiers, la passe complète de fin de tour aussi.

- argPreview : lecture incrémentale de l'argument affiché et du corps, même
  règle que previewArgDone (barre oblique coupée gardée en attente) ;
  arguments accumulés dans un strings.Builder, cur.Function.Arguments recalé
  sur lui à chaque morceau.
- Événement de frappe : seules les 40 dernières lignes (4 Kio au plus) du
  corps, avec body_lines (vrai « +N ») et body_tail ; l'UI affiche « … » et
  garde le repli sur le décompte local.
- textualToolCallFrom : ne relit que la fin, depuis un vrai début de ligne
  avant la fin du balayage précédent, en reculant sur les suites que les
  motifs embarqués peuvent traverser. Surcharge retry_patterns : balayage
  complet, comme avant.
- Direct SSE : les événements trouvés à un réveil partent en une écriture
  (lots de 128 Kio / 256 événements au plus), un sceau par événement en E2E.
- Tests : fuzz d'argPreview contre previewArgDone, équivalence fenêtre /
  balayage complet au morceau près, corps borné de bout en bout, ordre des
  lots ; bancs d'essai.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:48:49 +02:00
MichaelandClaude Opus 5.5 eaf5e313d9 Cache : corrections de relecture — la ligne MCP et les descriptions d'outils partagent un seul format
mcpPromptLine relisait le préfixe « [MCP: <serveur>] » des descriptions en le
réécrivant à la main : changer le format dans mcpTools aurait fait disparaître
la ligne en silence. Un nom de serveur contenant « ] » y était aussi coupé.

- mcpTagDescription pose le préfixe, mcpToolServer le relit : un seul endroit.
- mcpToolServer retient la coupure dont la forme assainie est celle du nom
  d'outil — « a] b » reste « a] b ».
- Les tests qui appellent EnabledTools isolent $LOKI_HOME : la configuration
  MCP de la machine de test (et ses connexions) ne fausse plus le cas « sans
  outil MCP » ni le budget du préambule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:34:36 +02:00
MichaelandClaude Opus 5.5 7dd319a058 Cache : la ligne MCP du préambule ne manque plus au premier tour après un démarrage
Le préambule système était bâti (InjectSkills → baseSystemPrompt) AVANT que
runChat n'appelle EnabledTools, donc avant que mcpTools n'ouvre les connexions.
mcpPromptLine lisait le pool encore vide : le premier tour après un démarrage
partait sans la ligne MCP, le second avec — tout le prompt à recalculer pour
une ligne. Elle comptait en plus les outils masqués par l'utilisateur, et
s'affichait pour le planner, qui ne reçoit aucun outil MCP.

- prepareTurn calcule les outils UNE fois par tour, puis le préambule à partir
  d'eux ; runChatTools les reprend tels quels (pas de second passage par
  mcpEnsureAll, qui retentait deux fois un serveur en panne).
- mcpPromptLine se déduit de cette tranche : seuls les outils réellement
  envoyés sont comptés, et rien n'est annoncé sans outil MCP.
- trackerList départage les égalités d'horodatage par nom puis slug : la liste
  part dans le contexte, un ordre tiré au sort changeait le prompt.
- Le commentaire de tasks_run.go ne prétend plus que le préfixe d'une tâche
  égale celui du chat (dossier de travail et taskCaps diffèrent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:31:00 +02:00
MichaelandClaude Opus 5.5 9263df404d Cache : corrections de relecture — un rappel de loki ne laisse plus deux messages user d'affilée
Persister les rappels ouvrait trois façons de coller deux `user` dans
l'historique, celles-là même qu'appendNudge voulait éviter : sur un gabarit à
alternance stricte, chaque tour suivant aurait échoué.

- Fin de tour : un rappel resté sans réponse (stop, erreur) ou suivi d'un
  ajout en cours de réponse est retiré avant persistance (dropStrayNudges),
  comme avant sa persistance.
- Compaction : la queue ne commence plus sur un rappel ; la vraie demande
  réinjectée devant elle lui était collée. Elle recule d'un groupe d'outils.
- isLokiInjected ne se contente plus du préfixe « [system] » : un message de
  l'utilisateur qui commence ainsi n'est plus pris pour un rappel (sa demande
  était sautée par la compaction, le vérificateur et le titre).
- Relance tool_choice « none » : pas de repli après un stop, qui partait sur
  un contexte annulé et affichait une erreur.
- Tests : appel par le protocole et refus 4xx sous « none » (et rien
  d'exécuté), frontière de compaction, nettoyage de fin de tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:25:39 +02:00
MichaelandClaude Opus 5.5 38b488515a Cache : les rappels de loki et la relance après un 500 ne recalculent plus la conversation
Le rappel de budget d'outils et la relance « pensé sans agir » partaient au
moteur sans entrer dans l'historique : au tour suivant, le fil divergeait
juste avant eux et toute la boucle d'outils qui suivait était recalculée.
Le modèle relisait en prime une histoire qu'il n'avait jamais vue. La
relance après un 500 (appel d'outil mal parsé) retirait les outils et
modifiait le message système : tout le prompt repartait de zéro, deux fois.

- Rappels persistés tels qu'envoyés (appendNudge), sauf derrière un autre
  message user : éphémères là, pour ne pas casser à chaque tour les gabarits
  à alternance stricte.
- Ils restent reconnaissables (isLokiInjected) : la compaction ne les
  réinjecte pas comme demande en cours et ne les prend pas pour la preuve
  qu'une demande a été servie ; la réduction forcée coupe d'abord aux vraies
  demandes ; le vérificateur, l'export, le titre et le résumeur les sautent
  ou les présentent comme note du système.
- Relance après un 500 sur le llama-server local : mêmes outils, même
  message 0, tool_choice « none » et la consigne au bout du dernier message,
  jamais persistée. Nouveau refus, appel tenté malgré tout ou réponse vide :
  repli une fois sur le chemin historique. Preset externe inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:16:48 +02:00
MichaelandClaude Opus 5.5 72ba8e0fb6 Compaction : corrections de relecture — une balise </think> citée n'ampute plus le résumé
cleanSummary coupait toujours au DERNIER </think>. Un résumé de session
Code qui cite la balise (un parseur, un gabarit — sessions sur Loki même)
perdait donc tout ce qui la précédait, sans erreur ni trace : exactement la
perte que le commit précédent voulait supprimer.

- Le raisonnement ne se retire qu'en tête : un bloc <think>…</think> qui
  ouvre la réponse (plusieurs à la suite au besoin), ou un </think> sans
  <think> avant lui (gabarit qui ouvre le bloc dans le prompt).
- Un bloc cité en milieu de texte reste tel quel.
- La coupe par le filet (API qui ignore max_tokens) est tracée elle aussi.
- Tests : CTX fixé (le filet en dépend), réponse du faux moteur derrière un
  mutex, cas de balises citées et de blocs multiples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:04:03 +02:00
MichaelandClaude Opus 5.5 e14ead98c4 Compaction : le résumé n'est plus amputé de son état d'avancement
Le résumé de compaction était coupé net à 2200 caractères (≈ 550 tokens)
alors que max_tokens l'autorise jusqu'à ~1600 tokens. Le prompt du résumeur
place l'ÉTAT D'AVANCEMENT en dernier : c'était donc exactement ce dont
l'agent a besoin pour reprendre qui tombait, et les tokens décodés au-delà
étaient jetés.

- La borne passe à compactSummaryBudget()×6 caractères : un simple filet
  pour une API qui ignorerait max_tokens, qui borne déjà la sortie.
- finish_reason=length : le texte est gardé, marqué « […] » et tracé
  dans le journal ([compact] résumé coupé par max_tokens…).
- Un <think> ouvert en tête et jamais refermé, ou un contenu vide à côté
  d'un reasoning_content, est une erreur : l'appelant retombe sur le torse
  dégraissé au lieu d'installer du raisonnement brut comme mémoire.
- Tests : table de cleanSummary, et un résumé de 7000 caractères qui
  revient intact du moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:00:04 +02:00
MichaelandClaude Opus 5.5 26fc348506 Contexte : corrections de relecture — la garde de marge ne compacte plus en boucle, et le preset externe compte comme avant
La garde de marge de génération réclamait 1,2 × la plus longue génération
récente, sans plafond. Une étape de 40 k sur une fenêtre de 65 k (ou de 20 k
sur 32 k, sans budget de raisonnement) demandait plus de place que
l'historique fraîchement compacté n'en laisse : la garde repartait à chaque
étape, et chaque fois résumait le résumé précédent.

- Garde : jamais avant la moitié de la fenêtre. Au-delà, le rejeu sur
  « length » reste le filet.
- Preset externe : le complément « messages arrivés depuis la mesure » ne
  s'applique plus. Son CtxUsed garde le raisonnement, qui couvrait déjà ce
  complément ; l'ajouter le faisait compacter plus tôt qu'avant.
- Journal [ctx] : l'estimation d'avant compaction (début de tour, en tour)
  n'est plus comparée au compte d'une requête compactée, ce qui donnait de
  faux écarts.
- Tests : génération énorme juste après compaction, seuil de 50 % pour toutes
  les fenêtres, comptage externe contre local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:53:38 +02:00
MichaelandClaude Opus 5.5 d6023ef8a0 Contexte : le raisonnement jamais renvoyé ne compte plus, et la compaction ne part plus trop tôt
CtxUsed valait usage.prompt_tokens + généré, raisonnement compris. Or Loki
ne renvoie jamais ce raisonnement au modèle (Message n'a pas de champ pour
lui) : la requête suivante pesait 1 à 8 k jetons de moins que le compte, et
la compaction, qui perd de l'information, partait vers 44-47 k au lieu de
49 k sur une fenêtre de 65 k. Rien ne change dans les requêtes : seul le
compte qui décide de compacter.

- Comptage : seulement les morceaux reasoning_content séparés par le
  llama-server local, remis à zéro à chaque tentative. Le <think> découpé
  chez nous reste dans le message renvoyé pendant le tour, il n'est pas
  retiré ; l'API externe garde l'ancien calcul. Un morceau vaut au plus un
  jeton : on ne peut que sous-estimer, le sens sans danger.
- Début de tour : le message utilisateur (et la file) arrivé depuis la
  dernière mesure s'ajoute en estimation, comme en cours de tour.
- Vérificateur : sa trace isolée écrasait CtxUsed avec sa petite taille, et
  la fin de tour décidait sur ce chiffre-là. Plus maintenant ; la jauge de
  l'UI ne bouge pas non plus et suit le même calcul que le serveur.
- Marge de génération : le compte gonflé offrait une marge par accident.
  Elle est remplacée par une vraie garde — compacter si 1,2 × la plus
  longue génération récente ne tient plus dans la fenêtre. Plancher et
  marge seuls ne devancent jamais le seuil de 75 %.
- Fenêtre pleine en plein raisonnement (finish « length » au-delà du
  seuil) : compaction puis étape rejouée, au lieu du nudge « arrête de
  raisonner ».
- Journal [ctx] : estimation contre compte réel au-delà de 2 % d'écart,
  chaque « length » et chaque nudge, pour vérifier qu'ils n'augmentent pas.
- Le seuil reste à 75 % : la réserve fixe proposée (toolResultMax/3 + 6 k)
  laissait trop peu de place sans budget de raisonnement ni max_tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:45:55 +02:00
MichaelandClaude Opus 5.5 18a723a671 Télémétrie : corrections de relecture — compaction aussi tolérante qu'avant, pas d'alerte de cache sur une API externe, seuil qui suit EXTRA_ARGS
La relecture du relevé par complétion a trouvé quatre écarts, tous
d'affichage ou de robustesse ; rien ne touche aux requêtes ni au contexte.

- Compaction : la réponse lue d'un bloc passait de Decoder à Unmarshal,
  plus strict (un octet parasite après le JSON faisait échouer une
  compaction qui passait). Retour au Decoder, et perfWire fait de même.
- API externe : son cache suit ses propres règles, sans slot ni points de
  reprise à surveiller ici. Plus de ligne ambre « recalculés » pour elle ;
  le relevé reste dans l'anneau.
- Seuil d'alerte : même priorité qu'au lancement — -cms d'EXTRA_ARGS, puis
  LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT, puis CKPT_MIN_STEP ; -ub d'EXTRA_ARGS
  avant UBATCH.
- UI : « recalculés après sous-agent, client /v1 » au lieu des identifiants
  internes (subagent, foreign).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:34:57 +02:00
MichaelandClaude Opus 5.5 80b7793773 Télémétrie : chaque complétion dit enfin ce qu'elle a repris du cache, recalculé et accepté du brouillon
Jusqu'ici, impossible de savoir si une ligne volatile s'était glissée dans
le prompt, si une mise à jour du moteur avait cassé les points de reprise
ou si l'acceptation du MTP s'était effondrée : le chunk final de llama.cpp
porte ces chiffres (cache_n, draft_n, cached_tokens), on les jetait.

Lecture seule : aucun champ ajouté aux requêtes, et le comptage du
contexte (usage.prompt_tokens → CtxUsed → compaction) ne change pas.

- perf_log.go : décodage à part et tolérant (perfWire) — un compteur mal
  typé ne jette plus le chunk final ni ne fait échouer une compaction ;
  cached_tokens, strictement entier dans streamChunk, y déménage aussi.
- Inconnu n'est pas 0 : pointeurs, et l'UI garde « prefill N tok » quand
  le moteur ne dit rien de son cache.
- Nature (main, subagent, verify, task, compact, bench, foreign) portée
  par le contexte, pas par Caps ; TTFT au premier delta de tout type.
- Perte de cache = total précédent − cache_n, seulement quand les messages
  prolongent strictement ceux de la complétion précédente de la même
  discussion et de la même nature (empreintes cumulées) ; jamais sur un
  flux coupé. Ce qui s'est intercalé (sous-agent, client /v1, bench) est
  nommé.
- Anneau de 5000 entrées en mémoire, sans texte ; GET /api/perf/summary
  (derrière la clé) : taux de cache, recalcul par tour et par nature,
  médianes pp/tg par profondeur, acceptation du brouillon.
- Ligne [perf] sur stderr seulement avec LOKI_PERF_LOG.
- UI : « prefill X nouveaux / Y en cache », ambre au-delà d'un seuil
  relevé sur un modèle hybride (espacement des points de reprise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:29:17 +02:00
MichaelandClaude Opus 5.5 3ff56e01db Moteur : corrections de relecture du MTP opt-in — le contrôle après chargement voit enfin le journal, et un second lancement ne prend plus le jeton du premier
La relecture de 3a6ad6f a trouvé quatre trous, aucun ne touche la sortie du
modèle ; tous font que le garde-fou se trompe de verdict.

- Couches en RAM : lastOffload repartait de la dernière ligne contenant
  « load_model » — or llama-server écrit « load_model: initializing… » APRÈS
  le chargement, et « loading model tensors » aussi pour le brouillon. Le
  contrôle ne trouvait jamais rien, ou ne regardait que le brouillon. Repère
  désormais la ligne « loading model '<chemin>' » du serveur, et la pire des
  lignes offloaded compte (modèle ou brouillon).
- Jeton de tentative : il était consommé (et l'échec inscrit) AVANT le test du
  port. Un second « loki serve » refusé pendant que le premier chargeait
  marquait la configuration comme ratée. Lecture d'abord (specAutoPeek), puis
  rangement une fois le port libre (specAutoSettle).
- Process web : il effaçait le jeton sans le relire sous verrou, donc parfois
  celui d'un lancement plus récent. specAttemptClear ne touche qu'au sien.
- MODEL_DRAFT vers une tête EAGLE-3 ou dFlash : Loki imposait draft-simple, que
  le moteur ne peut pas charger. La note renvoie maintenant à -md et
  --spec-type dans EXTRA_ARGS.
- Une tête MTP choisie comme MODEL_DRAFT compte comme modèle utilisé (« utilisé
  par », garde à la suppression).
- Éditeur : choisir un brouillon sur un ancien preset dont le MTP est en
  --spec-type dans EXTRA_ARGS fait passer le preset à SPEC=mtp. Sans ça, le
  brouillon était ignoré.
- Tests : un journal réel (initializing après chargement, brouillon chargé à
  part), un second lancement qui ne fait que lire, le jeton d'un autre
  lancement gardé, les têtes eagle3/dflash, et MODEL_DRAFT dans les références.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:16:41 +02:00
MichaelandClaude Opus 5.5 3a6ad6f23b Moteur : décodage spéculatif MTP en opt-in — tête détectée dans le fichier, garde-fous VRAM, et un essai raté ne se rejoue pas
Une tête MTP (Qwen3.6/3.8, intégrée ou publiée à part en mtp-*.gguf) accélère
le décodage sans rien changer à la sortie : chaque jeton émis est tiré par
l'échantillonneur du modèle cible, un jeton du brouillon n'est gardé que s'il
coïncide. Le prix est ailleurs — 1 à 2 Go de VRAM, un prefill parfois plus
lent, une fonction très récente du moteur — d'où l'opt-in : rien ne change
pour un preset existant.

- SPEC=off (défaut, clé absente comprise) / auto / mtp. La tête se reconnaît
  au TENSEUR blk.{N-1}.nextn.eh_proj (backend_gguf.go), comme llama.cpp : la
  clé nextn_predict_layers seule ne prouve rien.
- MODEL_DRAFT : résolu comme MMPROJ, mais introuvable ne bloque pas le
  lancement. Tête à part → -md + --spec-type draft-mtp explicite (llama.cpp ne
  devine que sur la première tranche) ; autre brouillon → draft-simple, sinon
  chargé en VRAM pour rien.
- auto seulement si --fit place tout (ni couches chiffrées, -ts, -ot, experts
  sur CPU, -sm row, --fit off, -dev), contexte chiffré (fit pourrait sinon le
  réduire), pas de vision, build officiel ≥ 11009, jamais qwen4exp. mtp impose,
  avec un avertissement.
- Aide du moteur lue à chaque fois : draft-mtp et --spec-draft-n-max requis ;
  --draft/--draft-max jamais émis. EXTRA_ARGS (--spec-type, -md, -hfd,
  --spec-default…) et LLAMA_ARG_SPEC_* gardent la main.
- Tirage greedy fixé (exact pour toute chaîne). probabilistic seulement avec
  SPEC=mtp, refusé avec mirostat ou adaptive-p. SPEC_N_MAX → --spec-draft-n-max.
- Jeton de tentative : « loki serve » le pose avant de lancer, le process web
  l'efface dès que le moteur répond (sans page ouverte). Resté au lancement
  suivant, même preset, moteur et build → auto coupé et dit. Couches poussées
  en RAM après chargement : même verdict. Les erreurs MTP/brouillon du journal
  ont leur message.
- Éditeur : « auto » et « MTP » écrivent SPEC, « non » écrit SPEC=off ; champ
  Brouillon ; la recherche HF propose la tête MTP du dépôt, décochée.
- Tests : table de specArgs (gardes, sauts, brouillons, tirage), place avant
  EXTRA_ARGS, cycle du jeton sur une vraie base, lecture du journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:09:24 +02:00
MichaelandClaude Opus 5.5 7f49310b7f Moteur : corrections de relecture des points de reprise — un slot de plus compte, et un « --version » coincé ne bloque plus le lancement
La liste des points de reprise de llama-server est PAR SLOT : la garde de RAM
de l'espacement d'office ne comptait qu'une liste, et sous-estimait d'autant
le pire cas avec PARALLEL > 1. Et « loki serve » lit désormais le build du
moteur avant de le lancer (hybrides seulement) : sans délai, un binaire coincé
à l'initialisation de ses backends bloquait le lancement, là où binHelp, lui,
était déjà borné.

- ckptSlots : --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 ; « auto » ou
  illisible compté pour 4. Le pire cas devient points × slots × poids.
- engineBuildOf borné à 15 s (WaitDelay pour un descendant qui garde la
  sortie) : passé le délai, build inconnu, ni avis ni drapeau. Profite aussi
  au panneau Moteur.
- Tests : PARALLEL=2 garde l'espacement, PARALLEL=4 et --parallel 4 le
  retirent ; table de ckptSlots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:57:08 +02:00
MichaelandClaude Opus 5.5 dd27b81af0 Moteur : points de reprise des hybrides protégés sur les moteurs anciens, et la version précédente gardée après une mise à jour
Avant b10864 (PR #28302), llama-server effaçait un point de reprise trop
proche du précédent même quand la liste avait de la place : sur un hybride
(Qwen3.5/3.6, Qwen3-Next), une reprise retombait jusqu'à ~8 k jetons en
arrière, recalculés à chaque tour d'outil. Un point de reprise est une copie
exacte de l'état récurrent : rien de ce que voit le modèle ne change, seul le
temps de prefill et la RAM hôte. Aucune mise à jour du moteur ici.

- Build cru seulement pour un moteur officiel (image, téléchargé, précompilé)
  et au-dessus de 5000 : un compilé en clone superficiel (build 1) ou un fork
  n'a ni avis ni drapeau. --version gardé par binaire, taille et date.
- Avis « moteur trop ancien » dans le panneau Moteur et au lancement d'un
  hybride.
- Opt-in CTX_CHECKPOINTS (--ctx-checkpoints) et CKPT_MIN_STEP
  (--checkpoint-min-step, > 0 : 0 laisserait l'éviction FIFO jeter le point du
  début de la conversation). Gardes séparées : -ctxcp/--ctx-checkpoints/
  --swa-checkpoints et -cms/--checkpoint-min-step, plus LLAMA_ARG_*.
- D'office : --checkpoint-min-step 2048 seulement sur un hybride lu dans le
  GGUF, build officiel < 10864, aide qui connaît le drapeau, aucun réglage de
  l'utilisateur, points de reprise actifs, modèle sous 70 % de la RAM et 32
  points sous le quart de la RAM libre. Sinon l'avis seul ; sur un MoE mmappé
  trop gros, conseil CTX_CHECKPOINTS=8 à 16.
- REASONING_PRESERVE=on|off → --reasoning-preserve / --no-reasoning-preserve,
  seulement si la clé est posée et que l'aide le connaît. Rien par défaut :
  Loki ne renvoie pas la réflexion passée, et aucun choix global ne garde le
  rendu de tous les gabarits (Qwen3.6 contre Qwen3.8).
- Mise à jour du moteur : la version téléchargée qui tournait avant n'est plus
  supprimée ; le panneau propose d'y revenir sans réseau.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:52:31 +02:00
MichaelandClaude Opus 5.5 c72467ce4c Moteur : corrections de relecture de l'isolation des travaux annexes — un slot que le travail n'a jamais pris n'est plus effacé
Un sous-agent, une vérification, une tâche ou un bench qui échoue avant que le
moteur ne le serve (erreur avant l'envoi, refus 4xx, arrêt immédiat) laissait
le slot 0 tel quel : il portait encore la conversation, que l'effacement jetait
alors sans qu'elle soit dans le cache RAM — recalcul complet garanti, soit
exactement ce que l'isolation devait éviter.

- engineServed() : runChat (réponse 200 du moteur local) et le bench le
  notent ; un travail annexe n'efface que si le moteur a servi au moins une
  requête de Loki depuis son début (finishSideJob, testable sans moteur).
- Les prompts d'un travail annexe ne sont plus retenus comme « la conversation »
  par noteEnginePrompt : deux vérifications de suite comparaient sinon la
  conversation au prompt du vérificateur, et le conseil pouvait se tromper.
- CACHE_RAM et CACHE_ISOLATE figurent dans le modèle de configuration
  commenté (loki config).
- Éditeur : une valeur fixée par EXTRA_ARGS ou LLAMA_ARG_CACHE_RAM (-1, 0)
  ne s'affiche plus comme « défaut du moteur ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:44:33 +02:00
MichaelandClaude Opus 5.5 3565b594ea Moteur : cache de prompts dimensionné et travaux annexes isolés — la conversation n'est plus recalculée après un vérificateur ou un sous-agent
llama-server garde en RAM hôte une copie exacte des états de slot qu'il quitte
(KV, état récurrent, points de reprise, brouillon MTP) et la recharge octet pour
octet. Mais son défaut de 8 Gio ne tient pas une conversation de 30 à 65 k
jetons à côté de l'état d'un vérificateur, d'un sous-agent, d'une tâche ou d'un
bench : à la requête suivante il évince la conversation pour sauver l'annexe, et
tout est recalculé (30 à 80 s sur un 27B). Rien de ce que voit le modèle ne
change ici : seul le bruit de découpage des lots, comme le cache_prompt actuel.

- CACHE_RAM (Mio ; vide/auto, nombre passé tel quel, -1, 0) → --cache-ram,
  seulement si l'aide du moteur le connaît. Loki se tait devant -cram
  d'EXTRA_ARGS et LLAMA_ARG_CACHE_RAM. L'auto ne descend JAMAIS sous le défaut :
  il agrandit seulement un modèle tout-GPU (VRAM NVIDIA connue, aucun poids ni
  KV sur CPU, NGL complet), d'après l'état mesuré dans le GGUF (2,5 × KV à CTX
  + état récurrent et points de reprise des hybrides), plafonné à 30 % de la RAM
  (limite cgroup comprise) et à la moitié de la RAM libre.
- --slot-save-path LOKI_HOME/slots (0700, purgé au lancement) uniquement si le
  moteur est en boucle locale ou protégé par clé, et si le dossier existe. Les
  proxys /v1 et du relais refusent désormais toute action /slots (405).
- engineSideJob efface le slot 0 APRÈS le sous-agent, la passe de vérification,
  la tâche planifiée et le bench (pas après la compaction : rien à y gagner).
  Synchrone, borné à 2 s, et seulement si : moteur local, build ≥ 8660 lu dans
  /props, total_slots == 1, slot 0 au repos, aucune requête de Loki en vol
  (compteur tenu par le chat, les résumés, le bench et les proxys).
  CACHE_ISOLATE=off le coupe.
- usage.prompt_tokens_details.cached_tokens : si la conversation revient avec
  moins de la moitié en cache, conseil (une fois) de relever CACHE_RAM.
- Éditeur de preset : champ « Cache de prompts » à côté d'UBATCH, avec la
  valeur auto calculée par le serveur (/api/preset/cacheram).
- Lecteur GGUF : embedding_length, head_count et dimensions ssm.*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:37:55 +02:00
MichaelandClaude Opus 5.5 d028e81ce2 Modèles : corrections de relecture du lecteur GGUF — v2, première tranche sans tenseur et octets corrompus testés
La relecture n'a trouvé aucun défaut dans le lecteur lui-même : bornes,
versions, tranches, cache et vérification du début des données tiennent. Trois
cas réels restaient pourtant sans test.

- GGUF v2 : même disposition que v3, désormais vérifié avec v1 et gros-boutiste.
- gguf-split --no-tensor-first-split : la première tranche ne porte que les
  clés, aucun tenseur ; la tête MTP est trouvée dans la suivante.
- Un octet corrompu à chaque position de l'en-tête (v1 et v3, valeurs 0xFF,
  0x7F, 0x00) : le lecteur rend la main sans jamais tomber dans le filet du
  panic rattrapé, qui ne doit rester qu'un filet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:22:12 +02:00
MichaelandClaude Opus 5.5 0e841c26bd Modèles : lecteur des métadonnées GGUF
Plusieurs réglages du moteur dépendent de ce que le fichier contient vraiment
(tête MTP, têtes KV par couche, couches récurrentes d'un hybride) et Loki ne
le devinait qu'au nom du fichier. ggufMeta lit l'en-tête, les clés et la
table des tenseurs, jamais les poids. Aucun appelant pour l'instant : rien ne
change au lancement.

- Champs retenus : architecture, block_count, nextn_predict_layers,
  head_count_kv (valeur unique ou tableau par couche), key/value_length,
  expert_count, context_length, full_attention_interval, hybride (une clé
  <arch>.ssm.*).
- Tête MTP reconnue comme llama.cpp la reconnaît : le tenseur
  blk.{block_count-1}.nextn.eh_proj.weight, cherché dans toutes les
  tranches. La clé nextn seule ne prouve rien (tête publiée à part).
- Robuste : chaque longueur bornée par ce qui reste du fichier, compteurs et
  imbrication plafonnés, GGUF v1/v2/v3 et gros-boutiste, panic rattrapé,
  fichier coupé (en-tête ou début du dernier tenseur) refusé, tranche
  manquante refusée.
- Cache par chemin, taille et date de chaque tranche ; les erreurs ne sont
  pas gardées.
- Tests sur des GGUF synthétiques écrits par le test : hybride avec MTP, tête
  absente ou mal placée, tranches, v1 et gros-boutiste, toutes les
  coupures, fichiers aberrants, cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:17:23 +02:00
MichaelandClaude Opus 5.5 c4f051a0e9 Moteur : corrections de relecture des garde-fous de fidélité — les variables LLAMA_ARG_* et le glissement par défaut des anciens moteurs se voient aussi
Les garde-fous ne lisaient qu'EXTRA_ARGS et KV_TYPE. Or llama.cpp applique ses
variables LLAMA_ARG_* avant la ligne de commande : un conteneur lancé avec
LLAMA_ARG_CACHE_TYPE_K=q8_0 ou LLAMA_ARG_CACHE_REUSE=256 changeait les calculs
sans un mot. Et un llama-server d'avant --context-shift glisse le contexte PAR
DÉFAUT : il jetait des jetons alors que rien n'était écrit nulle part.

- Type de cache effectif : variables, puis KV_TYPE*, puis EXTRA_ARGS, la
  source la plus forte est nommée dans la note ; warnSlowKV suit.
- --context-shift et --cache-reuse lus aussi dans LLAMA_ARG_CONTEXT_SHIFT et
  LLAMA_ARG_CACHE_REUSE ; moteur ancien (aide sans --context-shift) : on dit
  qu'il glisse et que --no-context-shift l'en empêche. Aucun drapeau ajouté.
- Vision reconnue aussi par --mmproj d'EXTRA_ARGS et LLAMA_ARG_MMPROJ.
- Placement figé lu par tensorOverride : --n-cpu-moe 0 ne compte plus,
  --n-cpu-ffn et LLAMA_ARG_N_CPU_MOE si. Même règle dans l'interface.
- Test du paquet : les étiquettes json:"cache_prompt" des structures sont
  aussi refusées hors du benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:11:31 +02:00
MichaelandClaude Opus 5.5 2bf388f7b7 Moteur : garde-fous de fidélité — un cache KV quantifié ou un cache approché se voit, Loki n'en pose jamais
Le cache KV en q8_0 d'un preset MoE vivait dans EXTRA_ARGS : ni l'avertissement
de lenteur ni la liste de l'interface ne le voyaient, qui affichait « f16 (max
qualité) » pendant que le moteur tournait en q8_0. Rien n'empêchait non plus un
--context-shift ou un --cache-reuse de modifier en douce ce que voit le modèle.
Aucun drapeau n'est ajouté ni retiré : on DIT ce que la ligne finale change.

- Type de cache effectif (effectiveKVTypes) : KV_TYPE*, puis -ctk/-ctv et
  --cache-type-k/v d'EXTRA_ARGS par-dessus, la dernière occurrence gagne comme
  dans llama-server. warnSlowKV le reçoit désormais.
- Note au lancement quand ce cache n'est pas f16 : q8_0 modifie légèrement les
  sorties, q4_0 perte mesurable, bf16 numérique différente. Avec -ot ou
  --n-cpu-moe, --fit ne tourne pas : repasser en f16 demandera sans doute de
  relever --n-cpu-moe. Pas d'estimation de VRAM sans les métadonnées du GGUF.
- Avertissement pour --context-shift (jamais --no-context-shift) et
  --cache-reuse N>0 ; --swa-full, sans perte, n'en déclenche aucun. Vision
  chargée : llama.cpp les ignore, on le précise.
- Interface : options du cache KV étiquetées, sous-titre qui montre le type
  effectif (« défini par EXTRA_ARGS ») et les drapeaux de cache approché.
- Tests : notes et type effectif en table, aucun -ctk/-ctv sans KV_TYPE, corps
  réels du chat et de la compaction sans cache_prompt:false ni n_cache_reuse,
  et ces clés réservées au benchmark dans tout le paquet (arbre syntaxique).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:04:08 +02:00
MichaelandClaude Opus 5.5 a42c184ed2 Moteur : corrections de relecture de FIT_TARGET — -sm tensor coupe aussi --fit, -ngl -1 ne le coupe pas
common/fit.cpp abandonne sur LLAMA_SPLIT_MODE_TENSOR comme sur ROW
(« not implemented, abort ») : avec -sm tensor, la marge partait quand même
et la note annonçait un --fit-target qui ne servait à rien — de quoi mesurer
deux fois le même placement. À l'inverse, -ngl -1 est la valeur par défaut
du moteur (celle qu'écrit « auto ») : fit tourne, et la clé était refusée à
tort.

- fitBlocker : row ou tensor, en drapeau ou en LLAMA_ARG_SPLIT_MODE ;
  -1 traité comme auto.
- tests : -sm tensor, LLAMA_ARG_SPLIT_MODE=tensor, -sm layer, -ngl -1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:58:23 +02:00
MichaelandClaude Opus 5.5 9aa426a389 Moteur : trois réglages d'expert sur demande — marge VRAM par carte (FIT_TARGET), seuil d'offload des experts, branches Q/K/V parallèles
Sur deux cartes inégales (5060 Ti + 3060) ou un MoE aux experts sur CPU, il
reste quelques leviers de placement que llama.cpp expose mais que Loki ne
savait pas poser. Aucun ne touche au modèle (poids, contexte, cache,
échantillonnage) ; tous restent éteints tant que le preset ne les demande pas,
et le gain se décide à la mesure.

- FIT_TARGET=1024,3072 → --fit-target : marge libre par carte pour --fit.
  Validée (entiers, 8 valeurs au plus, jamais transmise brute : stoull ferait
  mourir le moteur en boucle), remontée à 1024 Mio au minimum. Seulement si
  l'aide la liste, sans -fitt ni LLAMA_ARG_FIT_TARGET déjà posés, avec un
  contexte chiffré (CTX=0 laisserait fit réduire le contexte) et si --fit
  tournera vraiment : NGL chiffré, --tensor-split, -ot, --n-cpu-moe, -cmoe,
  --fit off ou -sm row le coupent, et on le dit au lieu de ne rien faire.
- OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : les lots de moins de N
  jetons restent sur CPU pour les poids en RAM. Utile pour des brouillons
  ngram de 32 jetons ou plus ; MTP vérifie déjà sous 32.
- CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : expérimental, sortie identique en
  amont, gain de 0 à quelques % ; refusé si -sm row/tensor ou un -ot sur
  l'attention pouvait séparer Q, K et V d'une couche. Le lancement rappelle
  « off puis redémarrer » en cas de GGML_ASSERT.
- une variable déjà dans l'environnement n'est jamais touchée ; elles ne vont
  qu'à llama-server via « loki serve ».
- tensorOverride, extrait de pipelineBlocker, sert aussi à la garde de --fit.
- --backend-sampling écarté : pas identique au bit près, et inactif sur les
  requêtes à outils (grammaire) qui font l'essentiel du trafic.
- clés documentées dans le squelette de « loki edit » ; tests de table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:53:44 +02:00
MichaelandClaude Opus 5.5 2de20e0ab2 Moteur : corrections de relecture des files de lancement CUDA — les variables LLAMA_ARG_* lues comme llama.cpp les lit
La garde « pipeline possible » ne suivait pas tout à fait la façon dont
llama.cpp (common/arg.cpp) lit ses réglages. Faux positifs : 4x posé sans
pipeline possible, donc exposé au seul cas de panne connu sans aucun gain.
Faux négatifs : gain refusé à tort.

- surcharges de tenseurs cumulées : variable et drapeaux s'additionnent.
  Un --n-cpu-moe 0 n'annule donc ni un LLAMA_ARG_N_CPU_MOE=30 ni un
  --n-cpu-moe 20 placé avant lui.
- --n-cpu-ffn/-ncffn (FFN dense sur CPU) et LLAMA_ARG_OVERRIDE_TENSOR
  comptent comme des surcharges.
- cache KV : le dernier -kvo/-nkvo l'emporte. Sinon, la seule présence de
  LLAMA_ARG_NO_KV_OFFLOAD (même à 0) vaut « non », puis LLAMA_ARG_KV_OFFLOAD
  est lu comme un booléen.
- LLAMA_ARG_CPU_MOE n'est actif que pour on/enabled/true/1.
- NGL absent : Loki passe -ngl lui-même, donc LLAMA_ARG_N_GPU_LAYERS ne
  compte plus. NGL=Auto se lit sans tenir compte de la casse, comme nglArgs.
- buildServeArgs : le commentaire des threads retrouve son code.
- 12 cas de table en plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:47:38 +02:00
MichaelandClaude Opus 5.5 f7f1a30d08 Moteur : files de lancement CUDA élargies (4x) sur deux GPU quand le pipeline entre cartes est possible
Sur plusieurs cartes, llama.cpp fait travailler les GPU en pipeline quand le
modèle y tient en entier ; encore faut-il que le CPU empile assez de
lancements CUDA d'avance. CUDA_SCALE_LAUNCH_QUEUES=4x agrandit cette file du
pilote : mêmes noyaux, même ordre, sortie identique — seul le prompt peut
gagner (+10-25 % en amont sur un 70B, non mesuré ici), le décodage ne bouge pas.
llama.cpp l'a retiré de ses défauts après des blocages sur Jetson : Loki ne le
pose donc que là où le pipeline peut réellement exister.

- launchQueuesEnv (pure) : 4x seulement si ≥ 2 GPU CUDA servis (--device,
  sinon CUDA_VISIBLE_DEVICES, sinon nvidia-smi borné à 3 s) et aucun bloqueur :
  -ot, --cpu-moe, --n-cpu-moe ≠ 0, -nkvo, -sm autre que layer, NGL chiffré
  hors 999 — drapeaux d'EXTRA_ARGS ou LLAMA_ARG_* équivalents
- une variable déjà dans l'environnement n'est jamais touchée ;
  CUDA_LAUNCH_QUEUES=off la coupe, 0.25x/0.5x/2x/4x l'imposent, toute autre
  valeur est ignorée en le disant
- la valeur posée s'affiche sur la ligne « [loki serve] » ; la ligne de
  commande ne change pas, BATCH/UBATCH non plus
- CUDA_LAUNCH_QUEUES survit aux bascules de preset (réglage machine qu'un
  preset peut imposer)
- éditeur : « Experts MoE sur CPU » rappelle que ça coupe le pipeline sur 2 GPU

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:43:04 +02:00
MichaelandClaude Opus 5.5 0a02604968 Moteur : corrections de relecture des threads CPU — un -tb absent n'efface plus les réglages batch d'EXTRA_ARGS
Sans -tb, llama.cpp ne recopie pas seulement le nombre de -t : il remplace
TOUT le réglage CPU du batch par celui de -t (postprocess_cpu_params,
« cpuparams = *role_model »). Un -Cb, -Crb, --cpu-strict-batch, --prio-batch
ou --poll-batch écrit dans EXTRA_ARGS était donc effacé sans un mot depuis que
Loki ne pose plus « -tb 0 » — un réglage utilisateur cassé.

- Dans ce cas seulement, Loki pose -tb : la valeur de -t quand elle est connue
  (THREADS, sonde du conteneur ou -t / --threads d'EXTRA_ARGS), sinon 0,
  l'ancien comportement, et le dit sur stderr.
- Un -tb déjà dans EXTRA_ARGS reste maître, comme avant.
- flagValue lit la dernière occurrence d'un drapeau (« -t 6 » ou « -t=6 »).
- Tests : quatre lignes de construction d'arguments et une table flagValue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:37:59 +02:00
MichaelandClaude Opus 5.5 c6c5dc2b7e Moteur : « auto » des threads CPU = les cœurs physiques, plus tous les threads logiques
Loki passait toujours « -t <THREADS|0> -tb <THREADS_BATCH|0> ». Pour
llama.cpp, 0 veut dire hardware_concurrency() : tous les threads logiques,
frères SMT compris. Avec l'attente active par défaut (--poll 50), deux
threads sur un même cœur se gênent, et c'est le décodage des experts MoE sur
CPU qui paie. Le vrai auto du moteur, c'est l'absence du drapeau : il prend
alors ses cœurs physiques (cœurs P seulement sur Intel hybride sous Linux).

- THREADS vide ou 0 : plus de -t ; THREADS_BATCH vide ou 0 : plus de -tb,
  le moteur recopie -t (prefill ET vérification spéculative MTP).
- Valeur illisible ou négative : ignorée et dite sur stderr, au lieu de
  faire boucler le moteur sur son analyse d'arguments.
- -t / -tb déjà dans EXTRA_ARGS : Loki ne double plus le drapeau.
- Linux, conteneur à l'étroit (cpuset restreint, quota CFS) : le moteur se
  rabattrait sur tous les cœurs de l'hôte. Une sonde compte les cœurs permis
  comme llama.cpp (thread_siblings, cœurs E écartés), plafonne au quota et
  ne pose -t que s'il est plus petit ; lecture ratée = rien. Muette si
  LLAMA_ARG_THREADS est posé ou si EXTRA_ARGS fixe l'affinité (-C, -Cr…).
- Libellés de l'interface et du gabarit de config corrigés.

Le calcul du modèle ne change pas. Gain non mesuré : comparer tg du preset
MoE à physiques, physiques-1 et logiques avant de conclure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:32:14 +02:00
MichaelandClaude Opus 5.5 03eb56aee3 Moteur : corrections de relecture de la construction des arguments
buildServeArgs se disait pure, mais la traduction des drapeaux de chargement
qu'elle appelle écrivait encore sur stderr quand un --load-mode dio tombait sur
un moteur ancien. La note est désormais rendue comme les autres et affichée
par cmdServe, au même rang qu'avant (avant la note NGL).

- normalizeLoadFlags / downgradeLoadMode rendent la note au lieu de l'écrire.
- Table de tests : cas dio sur moteur ancien (drapeau retiré, une note), et
  TestNormalizeLoadFlags vérifie que seul ce cas produit une note.
- Aucun changement de ligne de commande.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:27:04 +02:00
MichaelandClaude Opus 5.5 76f5697487 Moteur : construction des arguments de llama-server isolée et testée
cmdServe mêlait deux métiers : sonder le monde (chemins du modèle et du
projecteur, aide du moteur, clé d'API, port) et composer la ligne de
commande. Tout réglage de lancement à venir devait donc se vérifier en
relançant un moteur. La composition passe dans buildServeArgs, pure, qui
reçoit ce que cmdServe a sondé et rend arguments, variables d'environnement
et notes ; cmdServe garde seul les effets (Setenv, chdir, port, exec).

- serveSysInfo porte l'aide du moteur, les chemins résolus et la clé.
- Les tests de capacité lisent le texte de l'aide (helpSupports*), plus le
  binaire : une aide fabriquée suffit à les exercer.
- La sélection GPU reste posée avant la lecture de l'aide, comme avant.
- Aucun changement de comportement : même ordre, mêmes valeurs. Une table
  de tests fige la ligne pour les presets courants (dense, NGL forcé,
  MoE avec -ot/--n-cpu-moe/-ctk, raisonnement on/off, vision, clé d'API,
  drapeaux de chargement, CUDA_VISIBLE_DEVICES).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:24:18 +02:00
MichaelandClaude Opus 5.5 aebecd75bd Accès refusé : l'interface dit pourquoi et comment le lever
Depuis la protection anti-site-tiers (0.14.0), un accès par nom de domaine
derrière un reverse proxy, sans clé de pilotage, reçoit un 403 sur toute
l'API. L'interface restait vide : chaque appel échouait en silence dans la
console.

- Au premier 403, un bandeau fixe affiche la raison donnée par le serveur
  et le correctif : LOKI_TRUSTED_HOSTS=<le nom affiché> dans les variables
  du conteneur, ou une clé de pilotage.
- docker-compose.yml et docker-compose.unraid.yml exposent la variable
  LOKI_TRUSTED_HOSTS, commentée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:11:19 +02:00
MichaelandClaude Opus 5.5 cf8d1cfaaa Version 0.14.0
Rattrapage d'AJEAN jusqu'à la 0.17.6 (contexte, reprise réseau, presets
externes, tâches ponctuelles, file d'attente) et reprises d'OpenFox pour le
mode Code (vérification quand le builder a fini, pré-vol des écritures,
relance des appels textuels, alias d'outils, consignes du dépôt, terminal).
Les 19 commits locaux de la synchro 0.13.6 → 0.16.3, restés hors de main,
y sont rejoués.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-23 07:52:59 +00:00
Claude f1075030dd NOTICE : créditer OpenFox pour les sous-agents et le fenêtrage du fil
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:08:15 +00:00
Claude c4e399e101 Mode code : sous-agents (explorer, code-reviewer, planner)
Reprise de l'idée des sous-agents d'OpenFox, sur la mécanique déjà en place
pour la passe de vérification : un runChat isolé, non persisté.

L'outil `subagent` délègue une question bornée à un rôle qui travaille dans
SON propre contexte et ne rend que sa réponse. Sur un modèle local, c'est la
fenêtre de contexte qu'on sauve : « trouve où est géré le cache » coûte dix
lectures de fichiers qui restaient ensuite dans l'historique jusqu'à la
compaction, alors que seule la réponse comptait.

Les rôles explorer et code-reviewer, jusqu'ici définis mais jamais appelés,
deviennent utilisables. Tous les rôles délégués sont en LECTURE SEULE : pas
de write/edit (ce qui modifie le dépôt reste dans le fil principal, sous les
yeux de l'utilisateur), pas de subagent (aucune récursion), pas de mémoire ni
de web. Seul le planner pose des critères — son prompt le lui demande — et
marquer un critère « passé » reste le privilège de la passe de vérification.

Le rôle verifier n'est PAS délégable : le builder se décernerait son propre
satisfecit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:08:01 +00:00
Claude c32fd40f1a Longues discussions : ne rejouer que la fin, charger le début d'un clic
Inspiré du fenêtrage du fil d'OpenFox (2.0.151+). À chaque ouverture
d'onglet, le serveur rejouait TOUT le journal d'affichage : sur une
discussion de plusieurs centaines de tours, des dizaines de milliers
d'événements traversaient le flux, puis autant de bulles s'installaient dans
le DOM que le navigateur devait traîner à chaque rendu.

Le replay initial est désormais borné à ses 4000 derniers événements. Rien
n'est tronqué sur le disque : le serveur annonce combien d'événements sont
restés en arrière, l'interface l'affiche en tête du fil et le bouton
« charger le début » se réabonne en demandant le journal entier.

Jamais borné sur une reprise de flux (from > 0) : là, le client a déjà le
début à l'écran et attend la suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:04:58 +00:00
Claude 382c5dbdce Interface en anglais, par-dessus une source française
L'amont tient une table de clés FR/EN et marque chaque texte d'un data-i18n.
Reproduire ça ici demanderait de réécrire tous les écrans d'un coup, avec le
risque d'en casser un pour une clé oubliée — et un « settings.memory.title »
affiché en production.

Chemin additif : la source RESTE le français, et « English » applique un
dictionnaire sur le texte affiché (correspondance exacte, nœud par nœud).
Une chaîne absente du dictionnaire reste en français ; le dictionnaire
s'enrichit sans toucher au reste de l'interface.

Jamais traduits : le fil de discussion (#chat), le code, les zones de
saisie, et tout ce qui porte data-no-i18n. Un observateur couvre les
panneaux rendus en JS après le chargement. Revenir au français recharge la
page — le texte d'origine a été remplacé dans le DOM, c'est le moyen sûr de
le retrouver intact.

Couverture de départ : navigation des réglages, intitulés de sections,
libellés de lignes, boutons et interrupteurs. Sélecteur dans Apparence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:01:36 +00:00
Claude fa69dac77f Chiffrement de la mémoire, snapshots, sauvegarde chiffrée en fichier
Reprise d'AJEAN (mem_crypto/mem_vault/mem_store/mem_io/mem_migrate/
mem_snapshots/mem_health/mem_fsutil + backup_bundle), adaptée à Loki.

Chiffrement à enveloppe : une DEK tirée une fois chiffre les données en
AES-256-GCM ; elle est enfermée dans un coffre par une KEK dérivée en
Argon2id. Ce qui ouvre le coffre : la clé de pilotage de l'appareil (le
serveur n'en a que l'empreinte, il ne peut pas ouvrir seul) ou la clé de
récupération donnée une fois. La DEK ne vit qu'en RAM.

Périmètre chiffré, choisi pour Loki : les pages mémoire, les discussions
(journal ET index, qui porte les titres), les blocs archivés au compactage
— du verbatim de conversation — et les trackers. Pas les buckets de
réglages : le coffre lui-même y vit, les chiffrer serait une boucle.

Verrouillé, rien n'est écrasé : putStoreBytes REFUSE d'écrire du clair
par-dessus du chiffré, la liste des discussions se lit vide et les pages
s'affichent « 🔒 chiffré ». Le déverrouillage recharge la discussion et
rattrape ce qui serait resté en clair. Une migration interrompue reprend au
démarrage, un snapshot est pris avant chaque bascule, et aucune donnée n'est
supprimée avant relecture vérifiée de son remplaçant.

Piège corrigé au passage : chiffrer À L'INTÉRIEUR d'une transaction bbolt se
bloquait sur le verrou de la base (memEncActive relit la config, donc la
base). L'état du chiffrement est désormais résolu AVANT la transaction
(memEncoderNow), qui ne reçoit plus qu'un encodeur pur.

Sauvegarde : le paquet chiffré {mémoire, presets, réglages} s'exporte et
s'importe en FICHIER (/api/backup/export, /api/backup/import). La
sauvegarde vers le relais ajean.link n'est pas reprise — c'est le service de
l'auteur amont ; ici le fichier reste chez soi.

Le dossier mémoire n'était déjà plus joignable qu'aux outils mem_* : c'était
la condition de ce chiffrement (un `cat memory/…` aurait rendu du binaire).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:59:10 +00:00
Claude e9ec8ae3a8 Notifications Web Push (+ manifeste PWA)
Reprise d'AJEAN : le serveur pousse une notification vers les navigateurs
abonnés, directement via leur service de push — donc app fermée et téléphone
verrouillé, là où une notification côté page ne peut rien (un onglet caché
relâche son flux SSE).

Deux déclencheurs : la fin d'un tour utilisateur (sauf interruption par le
bouton stop : celui qui a coupé est devant l'écran) et — ajout propre à Loki
— la fin d'une TÂCHE PLANIFIÉE, succès comme échec. C'est le cas qui compte
le plus : une tâche tourne justement quand personne ne regarde.

Clés VAPID générées à la première demande et rangées dans la base ;
abonnements persistés et purgés quand le service de push répond 404/410.
Corps de notification générique, sans extrait de réponse : elle transite par
Apple ou Google. /sw.js et /manifest.webmanifest sont servis à la racine
(un service worker doit venir de l'origine) ; le worker ne fait QUE recevoir
les push, sans cache — mettre l'UI en cache servirait une interface périmée
après une mise à jour de l'image.

Interrupteur dans Réglages → Mode agent, à armer sur chaque appareil. L'UI
dit ce qui manque plutôt que d'échouer : HTTPS requis, notifications
bloquées, ou iPhone à ajouter d'abord à l'écran d'accueil.

Nouvelle dépendance : github.com/SherClockHolmes/webpush-go (RFC 8291).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:37:16 +00:00
Claude 74fb1fc781 Tâches : scripts planifiés sans modèle, et l'IA planifie la sienne
Deux reprises d'AJEAN autour des tâches planifiées.

Dossier de scripts (/data/scripts) : un dossier durable À CÔTÉ de memory,
hors du workspace. Le workspace est jetable — supprimer une discussion
emporte ses fichiers — donc un script qu'on veut garder n'y avait pas sa
place. Le briefing machine l'annonce à l'IA, qui y écrit et y lance ses
scripts normalement.

Tâche « script seul » (Task.Kind/Script) : le planificateur lance le script
sans charger le modèle ni consommer un token, sa sortie devient le
compte-rendu, et l'UI l'affiche comme n'importe quelle tâche. Elle tourne
hors du verrou de génération — d'où un registre à part pour l'afficher « en
cours » et l'arrêter (/api/tasks/stop), et un « tester » qui n'attend ni le
verrou ni le moteur. Sélecteur Consigne IA / Script seul dans la modale.

Outils task_list/create/update/delete : l'IA se donne elle-même rappels et
veilles récurrentes, cloisonnés par projet (dans un projet, elle ne voit et
ne pilote que ses tâches). Le budget du préambule passe de 8600 à 11000
caractères, avec le palier documenté dans le test.

Dossier mémoire réservé à ses outils : bash, write et edit ne le touchent
plus (guardToolOnly*). Un `cat memory/…` contournait l'index MEMORY.md, et
c'est la condition d'un chiffrement de la mémoire à venir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:33:39 +00:00
Claude 91d1796615 Résultats d'outils : aperçu + « voir plus », vrai compteur, MCP complet
Le flux n'envoie plus tout le résultat d'un outil : un aperçu de 1600
caractères, sa taille RÉELLE et l'id de l'appel. Le bouton « voir plus »
charge le reste à la demande (/api/chat/tool-result), et déplie vraiment le
bloc (plafond de 280 px levé) au lieu de le laisser scroller. « voir plus »
et « copier » vivent dans la même barre, ancrée en bas à droite d'un
bloc-parent non scrollant : elle reste au coin quand on scrolle le résultat.

Le compteur « ~N tok » de la bulle disait la taille de ce que l'UI avait
reçu, donc toujours le plafond sur un long résultat. Il lit maintenant
result_chars, la taille réelle.

MCP : plus de troncature à 12000 caractères avant le modèle (même règle que
la lecture mémoire). Un serveur MCP est configuré exprès pour ce qu'il rend ;
le couper au milieu rendait la réponse inutilisable. L'UI, elle, n'en affiche
que l'aperçu.

Reprises d'AJEAN 0.15.1, 0.15.2 et 0.15.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:24:53 +00:00
Claude 00b2350fb9 Navigateur piloté, images redressées : reprises d'AJEAN
L'amont (AJEAN 0.15) donne à l'IA le contrôle d'un navigateur ; Loki
embarquait déjà un Chromium pour `web_screenshot` sans jamais le piloter.

Contrôle du navigateur (computer_use.go, computer_cdp.go, browser_grid.go,
portés d'AJEAN) : l'IA ouvre une page en CDP, en reçoit les éléments
interactifs NUMÉROTÉS et agit par numéro (browser_open/snapshot/find/click/
type/key/scroll). Aucune vision requise — un petit modèle texte s'en sort.
Avec un projecteur chargé s'ajoutent browser_screenshot (image quadrillée)
et browser_click_xy. Interrupteur dédié (Réglages, `loki computer`,
/api/computer), sous le mode agent : cliquer et taper dans une page sont des
actions réelles, même niveau de confiance que bash.

Adaptation au conteneur : chromePath cherche D'ABORD le Chromium de
Playwright (PLAYWRIGHT_BROWSERS_PATH, /opt/pw-browsers), le seul navigateur
de l'image — sinon la fonctionnalité se déclarait absente là où le
navigateur est présent. LOKI_CHROME force un chemin, LOKI_CU_HEADFUL ouvre
une vraie fenêtre.

Préparation des images (web_upload_orient.go, porté d'AJEAN) : l'orientation
EXIF est cuite dans les pixels — le projecteur l'ignore et voyait les photos
de téléphone couchées — et le grand côté ramené sous 1568 px. Appliquée aux
pièces jointes, à see_image et aux captures.

Dédup des appels d'outils : bash, bash_bg, bash_tail, see_image et les
browser_* ne sont plus court-circuités sur un appel identique. Relancer la
même commande après avoir modifié un fichier est légitime, et un outil qui
porte une image dans un message à part renvoyait « [déjà fait] » SANS
l'image — le modèle tournait en boucle.

Mode code : le builder ne publie plus de lui-même (pas de commit/push/reset/
rebase ni de redémarrage de service sans demande explicite), reprise de la
leçon d'AJEAN 0.15.4 ; l'inspection en lecture seule reste encouragée.

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

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

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

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

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

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

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

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

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

## see_image (chat_vision_tool.go)

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

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

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

## Au passage

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

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

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

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

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

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

Trois causes, trois correctifs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Pour la durée, deux causes distinctes :

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Deux points tranchés en cours de route :

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Version 0.12.3.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Intensité du raisonnement (REASONING_EFFORT)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ce qui change concrètement :

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 15:33:55 +00:00
LogiFlow d42c334b9a Merge pull request #5 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Claude/ajean loki container fork aep9r2
2026-08-16 14:37:02 +02:00
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
543 changed files with 125099 additions and 19001 deletions

No files matched your search

+9 -24
View File
@@ -1,26 +1,11 @@
# Contrôle de version
.git
.gitignore
# Python
**/__pycache__
**/*.pyc
backend/.venv
backend/venv
# Node / build
frontend/node_modules
frontend/dist
**/.vite
frontend/tsconfig.tsbuildinfo
# Données & workspace de runtime (montés en volume)
data
workspace
# Environnement / IDE
.github
data/
models/
docs/
dist/
*.md
!README.md
.env
.env.local
.idea
.vscode
**/.DS_Store
.env.*
rust/target/
+12 -24
View File
@@ -1,26 +1,14 @@
# ── Loki — configuration ────────────────────────────────────────────────
# Hôte Ollama. En Docker, host.docker.internal pointe vers la machine hôte.
# En local (hors Docker), utilise http://localhost:11434
OLLAMA_HOST=http://host.docker.internal:11434
# ── Loki — configuration du build/run Docker ────────────────────────────
# (fork d'AJEAN : https://github.com/nathaninline/ajean)
# Modèle sélectionné par défaut au démarrage
DEFAULT_MODEL=llama3.1:8b
# Port de l'interface web (identique dedans/dehors).
LOKI_WEB_PORT=8090
# Dossier de travail de l'agent (monté en volume dans le conteneur)
WORKSPACE_DIR=/workspace
# Base SQLite (sessions, messages, config)
DATA_DIR=/data
# Port de l'application (identique dedans/dehors). 8717 par défaut.
PORT=8717
# 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=
# Optionnel — semé au PREMIER démarrage seulement (modifiable ensuite dans
# l'UI, qui garde la main) :
# Modèle initial : nom d'un .gguf présent dans ./models ou chemin complet.
LOKI_MODEL=
# Taille de contexte (défaut moteur : 32768)
LOKI_CTX=
# Couches déportées sur le GPU (défaut : 999 = tout)
LOKI_NGL=
+1
View File
@@ -0,0 +1 @@
*.go text eol=lf
+69
View File
@@ -0,0 +1,69 @@
# 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
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
rust:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
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
+68
View File
@@ -15,6 +15,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
# L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright
# (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais
# ces trois répertoires ne servent à rien ici et rendent une dizaine de
# gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image
# compilait whisper.cpp avec CUDA : la marge reste bonne à prendre.
- name: Libérer de l'espace disque
run: |
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
@@ -43,7 +54,64 @@ jobs:
push: true
build-args: |
LOKI_VERSION=${{ github.sha }}
LOKI_BUILD=${{ github.run_number }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
# binaire de dictée) tant que ses lignes du Dockerfile ne bougent
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
# Release « rolling » : les binaires que le conteneur installe lui-même au
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
# voit pas.
rolling:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: rolling-release
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: Outils musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
- name: Binaires
run: |
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
file dist/loki-rs-linux
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
abi=$(tr -d '[:space:]' < IMAGE_ABI)
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
cat dist/manifest.json dist/SHA256SUMS
- name: Publier
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view rolling >/dev/null 2>&1 || \
gh release create rolling --prerelease --target "$GITHUB_SHA" \
--title "rolling (mise à jour au démarrage)" \
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
gh release upload rolling dist/manifest.json --clobber
+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
+21 -21
View File
@@ -1,27 +1,27 @@
# Python
__pycache__/
*.pyc
.venv/
venv/
*.egg-info/
# Binaires compilés
/loki
/loki.exe
/dist/
# Node
node_modules/
frontend/dist/
.vite/
# Artefacts de build Go
*.exe
*.test
*.out
# Data & workspace (runtime)
data/*.db
data/*.sqlite3
workspace/*
!workspace/.gitkeep
# Env / IDE
.env
.env.local
# OS / éditeurs
.DS_Store
Thumbs.db
.idea/
.vscode/
# TS build cache
frontend/tsconfig.tsbuildinfo
# Ancienne application Python/React (fork pré-Go), plus utilisée : elle traîne
# encore sur certains postes de développement et ne doit JAMAIS revenir dans le
# dépôt — un « git add -A » distrait y a déjà versé 94 Mo de node_modules.
/backend/
/frontend/
.pytest_cache/
__pycache__/
node_modules/
# Rust (façade loki-rs)
rust/target/
+169 -35
View File
@@ -1,47 +1,181 @@
# ── Étape 1 : build du frontend ─────────────────────────────────────────
FROM node:20-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# ── Loki — image GPU (fork d'AJEAN, https://github.com/nathaninline/ajean)
#
# Deux étapes seulement :
# 1. compilation du binaire Go `loki` (UI embarquée via go:embed) ;
# 2. runtime = l'image serveur CUDA OFFICIELLE de llama.cpp — llama-server
# y est précompilé et maintenu par l'équipe amont (base nvidia/cuda
# 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) ────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
WORKSPACE_DIR=/workspace \
DATA_DIR=/data \
PORT=8717
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape
# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils
# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée.
# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
#
# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est
# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ».
ARG SHERPA_ONNX_VERSION=v1.13.7
ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983
# curl pour le HEALTHCHECK ; git pour le moteur code (commits Aider) ;
# nodejs/npm pour les serveurs MCP lancés via npx (Playwright, Context7…)
RUN apt-get update && apt-get install -y --no-install-recommends curl git nodejs npm \
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
FROM golang:1.25 AS gobuild
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 ter : façade Rust loki-rs ───────────────────────────────────
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
# le même que celui publié dans la release « rolling » et installé à chaud
# dans /data/bin par `loki-rs self-update`.
FROM rust:1-alpine AS rustbuild
RUN apk add --no-cache musl-dev
WORKDIR /src
COPY rust/ ./
RUN cargo build --release --locked && mkdir -p /out && cp target/release/loki-rs /out/loki-rs
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner
# plus d'une fois) laissent place à une extraction de 35 Mo.
#
# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à
# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il
# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur
# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au
# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et
# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche.
#
# L'empreinte est vérifiée : le binaire s'exécute sur la machine de
# l'utilisateur, une release remplacée en amont ne doit pas passer en silence.
FROM debian:12-slim AS asrfetch
ARG SHERPA_ONNX_VERSION
ARG SHERPA_ONNX_SHA256
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates bzip2 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eu; \
nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \
curl -fsSL -o /tmp/sherpa.tar.bz2 \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \
echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \
mkdir -p /out; \
tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \
"${nom}/bin/sherpa-onnx-offline-websocket-server"; \
chmod +x /out/sherpa-onnx-offline-websocket-server; \
/out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
# 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.
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
# à la demande depuis Paramètres → Moteur.
RUN apt-get update && apt-get install -y --no-install-recommends \
git tini ca-certificates curl gnupg python3 python3-venv \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt mcp-server-fetch
# Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
# npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
# langage — mais autant livrer l'image complète.
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
COPY backend/ ./backend/
# Frontend compilé servi en statique par FastAPI
COPY --from=frontend /app/frontend/dist ./backend/static
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
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.
ARG LOKI_VERSION=dev
ENV LOKI_VERSION=${LOKI_VERSION}
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
# NB : on tourne en root par défaut pour rester compatible avec les volumes
# montés d'Unraid (/mnt/user/appdata/...), souvent détenus par root. Pour
# durcir, surcharge l'utilisateur côté compose (ex. user: "99:100").
# llama-server et ses .so vivent dans /app (convention de l'image amont).
ENV LOKI_CONTAINER=1 \
LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
LD_LIBRARY_PATH=/app \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
EXPOSE 8717
WORKDIR /app/backend
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
# dépendances d'image les binaires exigent — un binaire publié pour une autre
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
ARG LOKI_BUILD=0
ENV LOKI_BUILD=${LOKI_BUILD}
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS "http://localhost:${PORT}/api/health" || exit 1
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
# conteneur — il n'est pas authentifié par défaut.
EXPOSE 8090
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
# Remplace le HEALTHCHECK de l'image amont (qui vise le moteur sur :8080).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS "http://localhost:${LOKI_WEB_PORT:-8090}/" >/dev/null || exit 1
WORKDIR /data
# Remplace l'ENTRYPOINT de l'image amont (/app/llama-server).
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
+1
View File
@@ -0,0 +1 @@
1
+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.
+69
View File
@@ -0,0 +1,69 @@
# 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. S'y
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
son propre contexte), la **vérification déclenchée quand le builder a
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
qui garde l'état du mode Code.
## Reprises depuis l'amont après le fork
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
présent dans l'image) et la **préparation des images** envoyées au modèle
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
inclus dans l'image : ils sont téléchargés à la demande.
## Services externes
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
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.
+1154 -262
View File
File diff suppressed because it is too large. Load diff
+149
View File
@@ -0,0 +1,149 @@
# Loki 0.15.0
Une version entièrement consacrée à la vitesse, avec une règle tenue de bout en
bout : **ne jamais dénaturer le modèle**. Rien ne touche à la quantification
des poids, à l'échantillonnage, à la température, au budget de raisonnement ni
au contenu du contexte. Ce qui est sans perte est actif d'office ; tout ce qui
changerait ce que voit le modèle, le placement des poids ou les slots du moteur
attend une clé. Sans clé, la requête et la ligne de commande restent celles
d'avant.
La section **Performance** du README rassemble toutes les clés, leur défaut et
leur prix.
## Actif d'office, sans perte
**La conversation n'est plus recalculée pour rien.**
- **Cache de prompts dimensionné** : le moteur garde en RAM une copie exacte des
conversations qu'il quitte. Son défaut ne tenait pas une conversation de 30 à
65 k jetons à côté d'un vérificateur ou d'un sous-agent : il l'évinçait, et
tout était recalculé (30 à 80 s sur un 27B). Il est maintenant agrandi pour un
modèle tout-GPU, et le slot est effacé après chaque travail annexe pour que la
conversation revienne du cache.
- **Préfixe stable** : les rappels de Loki entrent dans l'historique tels
qu'envoyés, la relance après un 500 ne change plus les outils, la ligne MCP ne
manque plus au premier tour après un démarrage, l'ordre des trackers ne bouge
plus. Chacun de ces écarts faisait recalculer toute la boucle d'outils.
- **Compaction moins tôt** : le raisonnement que Loki ne renvoie jamais ne
compte plus dans le contexte. La compaction (qui perd de l'information)
partait vers 44-47 k au lieu de 49 k sur une fenêtre de 65 k. Son résumé n'est
plus amputé de l'état d'avancement.
- **Hybrides (Qwen3.5/3.6) sur un moteur ancien** : `--checkpoint-min-step 2048`
d'office quand la RAM le permet, pour ne plus retomber jusqu'à ~8 k jetons en
arrière à chaque reprise.
**Le matériel est mieux employé.**
- **Threads CPU** : « auto » veut dire les cœurs physiques, plus tous les
threads logiques — c'est le décodage des experts MoE sur CPU qui payait.
- **Deux GPU** : file de lancements CUDA élargie quand le pipeline entre cartes
est possible (même calcul ; seul le prompt peut gagner).
**Loki lui-même pèse moins.**
- Écrire un gros fichier ne coûte plus un temps quadratique côté Go (jusqu'à
8 s de CPU volées au décodage sur un MoE).
- L'enregistrement de la discussion ne retarde plus le premier jeton.
- Un seul `nvidia-smi` pour tous les onglets, aucun pour un onglet caché, un
seul à l'ouverture de l'éditeur de preset.
- Unraid : un `/data` ou `/models` servi par la couche FUSE est signalé, avec
le remède.
**On peut enfin mesurer.**
- Chaque réponse dit ce qu'elle a repris du cache, recalculé et accepté du
brouillon ; `GET /api/perf/summary` en fait la synthèse par nature de requête
(`lost_events`, `lost_tokens` : le cache perdu, et après quoi).
- **Bench** honnête, en tâche de fond : mode complet à profondeur réelle, tours
qui reprennent le cache, rien d'enregistré sans timings réels.
- Une sonde du gabarit de chat dit, sans rien envoyer au modèle, si le rendu
reste stable d'un message à l'autre.
**Garde-fous.** Un cache KV quantifié, `--context-shift` ou `--cache-reuse`
posés à la main sont signalés. Un mode de chargement résident voué à l'échec
(poids restés en RAM au-delà de 90 %) est refusé avec la raison en clair —
`LOAD_GUARD=off` pour passer outre ; le refus n'est plus relancé en boucle par
systemd, et un moteur Vulkan sur cartes mixtes n'est plus refusé à tort.
## En opt-in : à essayer, à mesurer
| Clé | Ce qu'elle fait |
|---|---|
| `PREWARM=on` | prépare le prochain tour pendant que tu lis (`full` : aussi la discussion qu'on ouvre) |
| `COMPACT_CONTINUATION=on` | le résumé de compaction prolonge le prompt en cache au lieu d'être calculé à froid |
| `PROJ_SNAPSHOT=on` | bloc projet figé par discussion, changements livrés à part ; date et dossier sortent du système |
| `REASONING_ECHO=on` | renvoie au moteur local la réflexion du modèle, au format entraîné |
| `SPEC=auto` / `mtp` | décodage spéculatif MTP (tête détectée dans le fichier, garde-fous VRAM) |
| `SPEC=ngram` / `mtp+ngram` | n-grammes du contexte, utiles en mode Code |
| `SIDE_SLOT=on` | second slot pour les travaux annexes (VRAM en double) |
| `SLOT_PERSIST=on` | état du slot gardé à la bascule de preset |
| `SPLIT_MODE=tensor` | parallélisme de tenseurs entre cartes (expérimental) |
| `FIT_TARGET`, `OP_OFFLOAD_MIN_BATCH`, `CUDA_GRAPH_OPT` | réglages d'expert pour cartes inégales et MoE |
| `KEEP_TURN_IMAGES=on` | **zone grise** : garde les images des outils dans l'historique |
| `NUDGE_IN_TOOL=on` | **zone grise** : rappel de budget dans le résultat d'outil |
Et deux outils qui ne font rien d'eux-mêmes :
- **Optimiseur** (`loki tune`, bouton « Optimiser… ») : cherche lots, threads,
marges et placement sur un moteur d'essai isolé ; rien n'est écrit sans un
clic, et l'application est vérifiée puis défaite si le moteur ne tient pas.
- **« Dupliquer en placement auto… »** pour un MoE aux experts placés à la
main : une copie du preset où `--fit` les répartit.
## Ordre de test conseillé
Une étape à la fois, mesurée avant la suivante :
1. **Bench complet** du preset tel quel : la référence.
2. **`PREWARM` + `COMPACT_CONTINUATION`**, une vraie session de travail, puis
`/api/perf/summary` : le cache perdu (`lost`) doit baisser.
3. **Optimiseur** sur le preset en service.
4. **MoE** : « Dupliquer en placement auto… », bench complet des deux.
5. **Mise à jour du moteur** vers la version recommandée (encart du panneau
Moteur).
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP.
## Moteur : version recommandée et retour arrière
Quand le moteur officiel est antérieur à `b10864`, le panneau Moteur dit ce
qu'une mise à jour apporte (points de reprise des hybrides, MTP rapide) et la
propose — sur clic seulement, version vérifiée sur le registre. La version
quittée est gardée : **Revenir à la version précédente** y ramène sans réseau.
Depuis `b10763`, llama-server garde par défaut la réflexion des tours passés :
un gabarit comme Qwen3.6 la rendrait vide. Avant de basculer, Loki le détecte et
propose `REASONING_PRESERVE=off` ; après, il compare le rendu de l'ancien et du
nouveau moteur et signale le premier écart.
## Ce qui a été écarté
- **`--cache-reuse`** : réutilise du cache calculé sous un autre contexte, donc
change les sorties.
- **`--context-shift`** : retire des jetons du contexte, et ne marche pas sur
les hybrides.
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
mesurablement, pour ~2 Gio gagnés sur un 27B hybride. Reste un choix
explicite (`KV_TYPE`).
## Autres corrections
- Une compaction en plein tour ne fait plus disparaître le rappel des pages
mémoire lues.
- Un `loki tune` tué net ne laisse plus le moteur arrêté ; lancé avec un autre
`LOKI_HOME` que l'interface (`sudo`), il refuse au lieu de charger un essai à
côté d'un moteur vivant.
- L'ancienne option « n-grammes (mod) » de l'éditeur rejoint `SPEC=ngram`, avec
une migration proposée (jamais faite d'office).
- Accès refusé : l'interface dit pourquoi et comment le lever.
## Mise à jour
```
docker compose pull && docker compose up -d
```
Installation systemd (`loki install`) : l'unité du moteur gagne
`RestartPreventExitStatus=78`. `loki update` ne réécrit pas les unités ;
`sudo loki install`, ou `sudo systemctl edit loki-engine` avec cette ligne dans
`[Service]`, la met à jour.
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": 15,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 15,
"Patch": 0,
"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.15.0",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
"Translation": {
"LangID": "040C",
"CharsetID": "04B0"
}
}
}
View File
Whitespace-only changes.
+66 -29
View File
@@ -1,45 +1,82 @@
# ───────────────────────────────────────────────────────────────────────────
# 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
# sur GHCR par GitHub Actions à chaque push sur main.
# Loki est un fork conteneurisé d'AJEAN (github.com/nathaninline/ajean, MIT) :
# 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 :
# 1) Crée les dossiers de données (terminal Unraid) :
# mkdir -p /mnt/user/appdata/loki/workspace /mnt/user/appdata/loki/data
# 2) Plugin « Compose Manager » -> nouvelle stack -> colle ce fichier.
# 3) Adapte OLLAMA_HOST avec l'IP de ton serveur Unraid (Ollama sur :11434).
# 4) Compose Up. Interface : http://<ip-unraid>:8717
# 1) Plugin « Nvidia Driver » (Apps) installé, GPU visible (nvidia-smi).
# 2) Crée les dossiers de données (terminal Unraid) :
# mkdir -p /mnt/user/appdata/loki/data /mnt/user/appdata/loki/models
# 3) Plugin « Compose Manager » -> nouvelle stack -> colle ce fichier.
# 4) Compose Up. Interface : http://<ip-unraid>:8090
#
# Mise à jour : Compose Down puis Up (l'option « pull image » récupère la
# dernière version), ou « docker compose pull » avant Up.
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
# 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
# (GitHub -> Packages -> loki -> Package settings -> Change visibility),
# ou connecte Unraid à GHCR avec un token.
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
# Si le journal demande une nouvelle image : Compose Down puis Up avec
# « pull image », ou « docker compose pull » avant Up.
# ───────────────────────────────────────────────────────────────────────────
services:
loki:
image: ghcr.io/r0m1k3/loki:latest
container_name: loki
runtime: nvidia
ports:
# Un seul numéro de port, identique dedans/dehors (évite tout décalage).
# Pour changer de port : modifie 8717 AUX DEUX endroits + PORT ci-dessous.
- "8717:8717"
# Pour changer de port : modifie 8090 à gauche ET LOKI_WEB_PORT… ou
# seulement à gauche (ex. "8717:8090") si tu veux garder l'ancien port.
- "8090:8090"
environment:
# >>> À ADAPTER : IP de ton serveur Unraid (Ollama écoute sur :11434) <<<
- OLLAMA_HOST=http://192.168.1.10:11434
- DEFAULT_MODEL=gemma4:12b
- WORKSPACE_DIR=/workspace
- DATA_DIR=/data
- PORT=8717
# Optionnel : instance SearxNG pour l'outil web_search (sinon DuckDuckGo)
- SEARX_URL=
- NVIDIA_VISIBLE_DEVICES=all
# Optionnel : modèle initial (sinon, choisis-le dans l'UI au premier
# lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
# fichier dans /mnt/user/appdata/loki/models.
- LOKI_MODEL=
# Accès par NOM DE DOMAINE (reverse proxy, ex. loki.mondomaine.fr) sans
# clé de pilotage : liste ici le(s) nom(s), séparés par des virgules —
# sinon l'API répond 403 (protection contre les sites tiers / DNS
# rebinding). Inutile pour un accès par IP ou nom local.
- LOKI_TRUSTED_HOSTS=
volumes:
- /mnt/user/appdata/loki/workspace:/workspace
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
# démon en espace utilisateur. Loki le signale au démarrage (encart
# d'information, rien n'est perdu). Deux remèdes, au choix :
# - Recommandé (Unraid 6.12+) : partages en « Exclusive access ».
# Ce n'est pas une case mais un état : Settings -> Global Share
# Settings -> « Permit exclusive shares » = Yes, puis le partage
# appdata (et celui des modèles) entièrement sur UN pool — mover
# d'abord vers le pool, ensuite Secondary storage = None. La page du
# partage affiche alors « Exclusive access : Yes ». Redémarre le
# conteneur : le chemin /mnt/user reste le même, FUSE est
# court-circuité.
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
# et un modèle de 80 Go peut saturer la mémoire du serveur.
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
- /mnt/user/appdata/loki/data:/data
extra_hosts:
# Permet aussi OLLAMA_HOST=http://host.docker.internal:11434
- "host.docker.internal:host-gateway"
# Le HEALTHCHECK est défini dans l'image (utilise automatiquement le bon port).
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
# SSD dédié ».
# - /mnt/strata/loki:/data/strata
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
+29 -40
View File
@@ -1,47 +1,35 @@
# 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:
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
ports:
# Un seul numéro de port, identique dedans/dehors.
- "${PORT:-8717}:${PORT:-8717}"
- "${LOKI_WEB_PORT:-8090}:${LOKI_WEB_PORT:-8090}"
environment:
- OLLAMA_HOST=${OLLAMA_HOST:-http://host.docker.internal:11434}
- DEFAULT_MODEL=${DEFAULT_MODEL:-gemma4:12b}
- WORKSPACE_DIR=/workspace
- DATA_DIR=/data
- PORT=${PORT:-8717}
# Optionnel : instance SearxNG pour web_search (sinon DuckDuckGo)
- SEARX_URL=${SEARX_URL:-}
- LOKI_WEB_PORT=${LOKI_WEB_PORT:-8090}
# Semé UNE fois au premier démarrage (modifiable ensuite dans l'UI) :
- LOKI_MODEL=${LOKI_MODEL:-}
- LOKI_CTX=${LOKI_CTX:-}
- LOKI_NGL=${LOKI_NGL:-}
# Accès par nom de domaine (reverse proxy) sans clé de pilotage : nom(s)
# autorisé(s), séparés par des virgules — sinon l'API répond 403.
- LOKI_TRUSTED_HOSTS=${LOKI_TRUSTED_HOSTS:-}
volumes:
- ./workspace:/workspace
# /data = LOKI_HOME : config, base bbolt, mémoire, workspace, modèles téléchargés
- ./data:/data
extra_hosts:
# Permet d'atteindre un Ollama installé sur la machine hôte
- "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.
# /models : dossier(s) de modèles GGUF supplémentaires (LOKI_MODEL_DIRS)
- ./models:/models
deploy:
resources:
reservations:
@@ -49,7 +37,8 @@ services:
- driver: nvidia
count: all
capabilities: ["gpu"]
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
volumes:
ollama-models:
+98
View File
@@ -0,0 +1,98 @@
#!/bin/sh
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
# configuration au premier démarrage, lance le moteur (supervision PID, sans
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
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
# ── Mise à jour au démarrage ───────────────────────────────────────────────
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
/usr/local/bin/loki-rs self-update || true
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
# est plus récent que l'image et de même ABI, sinon l'image.
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
BIN_DIR=/usr/local/bin
fi
echo "[entrypoint] binaires Loki : $BIN_DIR"
# Tous les appels « loki » ci-dessous visent le binaire choisi.
loki() { "$BIN_DIR/loki" "$@"; }
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
if [ "$BIN_DIR" != /usr/local/bin ]; then
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
else
rm -f /usr/local/sbin/loki 2>/dev/null || true
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.
#
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
# chargeait la veille ne chargeait plus le lendemain, « unknown model
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
# le vide.
cur=$(loki config get BIN)
case "$cur" in
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
*) cur="" ;;
esac
if [ -n "$cur" ]; then
echo "[entrypoint] moteur mis à jour conservé : $cur"
else
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
fi
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
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
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
# si la façade pose problème.
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
if [ "${LOKI_FACADE:-1}" = "0" ]; then
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
fi
exec "$BIN_DIR/loki-rs" serve
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
@@ -0,0 +1,122 @@
# Migration Rust lot 1 — façade + mise à jour au démarrage — 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:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Global Constraints
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
## File Structure
```
IMAGE_ABI ABI d'image (entier)
rust/Cargo.toml workspace
rust/loki-rs/Cargo.toml
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
rust/loki-rs/src/child.rs supervision de `loki web`
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
rust/loki-rs/tests/proxy.rs intégration façade
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
internal/loki/web_server.go LOKI_WEB_BIND
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
.github/workflows/ci.yml job rust
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
README.md section mise à jour au démarrage
```
---
### Task 1: Go — adresse d'écoute configurable
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
**Interfaces (produits) :**
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
### Task 3: self-update (téléchargement vérifié, installation atomique)
**Files:** `src/update/mod.rs`, `tests/update.rs`
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 4: Façade proxy
**Files:** `src/proxy.rs`, `tests/proxy.rs`
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 5: Supervision du Go + commande `serve`
**Files:** `src/child.rs`, `src/main.rs`
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
- [ ] Commit.
### Task 6: Image, entrypoint, CI, release rolling, doc
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
- CI : job `rust` (fmt, clippy, test sur ubuntu).
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
## Self-review
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
@@ -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.
@@ -0,0 +1,150 @@
# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage
Date : 2026-10-13
Statut : validé (design), à planifier
## Contexte
Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors
tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote
llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le
réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des
semaines : la migration est **progressive**, par lots, avec Rust en production
dès le premier lot.
Second besoin : aujourd'hui, un `docker restart` relance la même image. Le
conteneur doit prendre la dernière version de Loki **au prochain démarrage**,
sans `docker compose pull` manuel.
## Architecture de cohabitation (« strangler fig »)
```
:8090 ──► loki-rs (Rust, axum/tokio)
├─ routes portées → traitées en Rust
└─ tout le reste → proxy HTTP / SSE / WebSocket
→ loki web (Go) sur 127.0.0.1:8091
```
- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique
(`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image.
- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute
de `loki web`). Le port public reste 8090 : compose et reverse proxy
inchangés.
- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert
de socle aux lots suivants.
- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant
qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source
(vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc
passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`,
`Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`
**sans les altérer**, et retire seulement les en-têtes hop-by-hop.
- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante.
Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte
anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de
Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le
forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé
le ferme pour `loki web` hors conteneur. Test dédié.
- Flux : pas de timeout d'écriture, pas de bufferisation des réponses
`text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé
(`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip.
- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot.
### Démarrage et arrêt
`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend
qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance
(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM,
la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server
arrêté proprement par Go, comme aujourd'hui).
## Mise à jour au démarrage
### Publication (CI)
À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la
release GitHub **`rolling`** (pré-release, assets écrasés) :
- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`)
- `loki-rs-linux` — binaire Rust musl
- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire)
- `manifest.json` — `{"commit": "<sha>", "image_abi": <entier>}`
La release `rolling` n'est jamais marquée `latest` : `loki update` (machines
non conteneurisées) continue de suivre les tags `vX.Y.Z`.
### ABI d'image
Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et
source du manifeste dans le même commit) quand le code exige un changement de
l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc.
Un binaire publié avec une ABI différente de celle de l'image n'est **jamais**
installé : il ne pourrait pas tourner correctement.
### Au boot (`loki-rs self-update`)
Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image :
1. `LOKI_AUTO_UPDATE=0` → ne fait rien.
2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6.
3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal :
« nouvelle version nécessitant une nouvelle image : docker compose pull »,
étape 6.
4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6.
5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre
`SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier.
Toute erreur → `.part` supprimés, rien remplacé.
6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué.
L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et
`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que
`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux
binaires viennent toujours de la même source (jamais un Go de l'image avec un
Rust de `/data/bin`). Le chemin Go choisi est passé à la façade
(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint.
Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp
se met à jour via l'UI (existant), le reste de l'image via un pull signalé par
l'ABI.
## Organisation du code Rust
```
rust/
Cargo.toml workspace
loki-rs/
src/main.rs CLI : serve | self-update | version
src/proxy.rs façade HTTP/SSE/WebSocket
src/child.rs supervision du processus Go
src/update/ manifeste, téléchargement, SHA256SUMS, installation
```
Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls,
sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`.
## Tests
- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision
d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`),
installation atomique (échec au milieu → rien remplacé).
- Intégration : façade devant un faux backend axum —
SSE reçu morceau par morceau (pas en bloc), WebSocket écho,
en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via`
entrant effacé, 502 si backend absent.
- Mise à jour : faux serveur de release local (URL de base injectable par
variable d'environnement de test).
- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un
job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée.
## Feuille de route des lots suivants (hors périmètre de ce spec)
| Lot | Contenu |
|---|---|
| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go |
| 3 | Supervision de llama-server |
| 4 | Client LLM, `/v1` OpenAI, flux chat SSE |
| 5 | Outils de l'agent, MCP, mode Code/LSP |
| 6 | Modèles et téléchargements, Strata, optimiseur, bench |
| 7 | Mémoire, tâches, dictée, relais, CDP |
| 8 | Store bbolt → redb, retrait de Go |
Chaque lot aura son propre spec, plan et PR.
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`;
}
Loaded 100 of 543 files, more files were not shown because too many files have changed in this diff. Show more