Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5 847aad217b MCP : catalogue embarqué de serveurs connus
Ajouter un serveur MCP demandait d'écrire soi-même `npx -y @scope/paquet …`
dans la modale, en devinant le nom du paquet. Le catalogue liste une vingtaine
de serveurs connus, commande déjà renseignée, classés par catégorie.

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

Intensité du raisonnement (REASONING_EFFORT)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ce qui change concrètement :

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+5
View File
@@ -43,6 +43,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
+107 -15
View File
@@ -22,7 +22,7 @@ seule image.
│ ▲ modèles .gguf │
│ chromium (Playwright) — captures de pages web │
│ /data (config, bbolt, presets, mémoire, │
│ workspace + captures, modèles installés) │
│ workspace par discussion, modèles) │
│ /models (GGUF déposés à la main) │
└────────────────────────────────────────────────────┘
```
@@ -122,8 +122,8 @@ et modèles compris.
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
| `/data/models/` | modèles téléchargés depuis l'interface |
| `/data/memory/` | pages de mémoire persistante (`.md`) |
| `/data/workspace/` | dossier de travail de l'agent |
| `/data/workspace/captures/<id>/` | captures d'écran, un dossier par discussion |
| `/data/workspace/` | racine du dossier de travail de l'agent |
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
| `/models` | GGUF déposés à la main depuis l'hôte (volume séparé, `LOKI_MODEL_DIRS`) |
@@ -154,16 +154,38 @@ Héritées d'AJEAN :
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
les serveurs écrits en JavaScript comme en Python.
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **API OpenAI-compatible** exposable (`network on`, protégée par clé).
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
fork la sert autrement que l'amont).
Ajoutées par ce fork :
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
playwright, context7, github…) avec leur commande déjà renseignée, classés par
catégorie. Choisir une entrée **n'installe rien** : ça remplit le formulaire
d'ajout, tu relis la commande — qui s'exécutera sur cette machine — puis tu
enregistres. Le catalogue est un JSON **embarqué dans le binaire**
(`internal/loki/mcp_catalog.json`), donc aucun appel à un annuaire distant :
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
- **Intensité du raisonnement** : en plus de l'interrupteur *Raisonnement*,
l'éditeur de preset expose un niveau — auto / aucune / basse / moyenne / haute
— envoyé à `llama-server` comme `reasoning_effort`. Réglage **par preset**,
donc de fait par modèle. `none` coupe le raisonnement ; les autres valeurs sont
passées au gabarit jinja du modèle, ce qui ne change le comportement que des
modèles qui les lisent (gpt-oss et apparentés) — ailleurs c'est ignoré sans
erreur, et le sous-titre du réglage le dit plutôt que de promettre un effet.
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. Supprimer une discussion
efface aussi ses captures d'écran (`workspace/captures/<id>/`), pour que le
disque ne se remplisse pas en silence.
repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
le disque ne se remplisse pas en silence.
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
@@ -174,12 +196,43 @@ Ajoutées par ce fork :
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
- **Panneau Fichiers** (bouton dossier du pied de carte) : ce que l'agent a
écrit dans son dossier de travail, avec navigation dans les sous-dossiers,
téléchargement et suppression. Un dossier affiche la taille de **tout** son
contenu — c'est ce qu'on libère en le supprimant. Le pied donne l'occupation
disque totale de l'agent. Les chemins sont bornés au dossier de travail, liens
symboliques résolus des deux côtés : rien du reste du disque n'est atteignable.
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
aucune requête vers un service de polices. Deux variantes : claire par défaut,
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
visibles en pied de colonne.
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
du moteur. Conséquence directe : l'API est joignable partout où l'interface
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
détectée est celle du bridge Docker).
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
l'interface le dit en rouge.
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
affichée suit celle du navigateur.
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
- L'ancienne exposition publique via le relais de l'amont
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
pouvait donc qu'échouer.
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
dans le fil.
- **Paramètres** : les réglages d'application (identité, apparence, accès
@@ -206,12 +259,13 @@ Retirées par ce fork :
|---|---|---|
| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` |
| Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | image officielle llama.cpp (`server-cuda`), précompilée |
| Panneau « Moteur » | propose d'installer/compiler | annonce le moteur fourni par l'image, sans proposer d'install |
| Panneau « Moteur » | propose d'installer/compiler | affiche la version qui tourne et la met à jour en un clic (sans rebuild) |
| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) |
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
| Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié |
| Historique de tchat | conversation unique | discussions multiples, titrées et persistées |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
@@ -238,6 +292,44 @@ docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b
Aucune compilation de llama.cpp n'a lieu : le moteur est maintenu et
précompilé par l'équipe amont (toutes architectures GPU courantes).
## Mettre à jour llama.cpp sans reconstruire l'image
llama.cpp publie plusieurs versions par jour ; l'image de Loki, elle, ne se
reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à la date
du dernier build, et le rattraper imposait un rebuild complet (2,6 Go) pour un
composant de 170 Mo.
**Réglages → Moteur** affiche désormais la version de llama.cpp qui tourne
(`b10450`, avec son commit) et deux boutons :
- **vérifier la version** — interroge le registre et dit s'il existe plus récent ;
- **mettre à jour le moteur** — télécharge `llama-server` et ses bibliothèques
depuis l'image officielle, `ghcr.io/ggml-org/llama.cpp:server-cuda`.
Ce qui est téléchargé n'est **pas l'image** : un manifeste OCI liste ses couches,
et seules celles qui portent `/app` sont récupérées (~170 Mo). Le runtime CUDA
(2 Go) et la base système sont déjà dans l'image de Loki. Le moteur atterrit dans
`/data/engine/<version>/`, donc sur le volume de données : il survit à un
`docker compose pull`.
La variante est déduite du moteur en place (CUDA, Vulkan, SYCL, MUSA ou CPU) :
une installation Vulkan ne se verra jamais proposer une image CUDA.
**Le garde-fou.** La mise à jour apporte llama.cpp, pas le runtime CUDA, qui
reste celui de l'image. Un llama.cpp compilé pour un CUDA plus récent ne
chargerait pas son backend GPU ici — et le symptôme serait silencieux : tout
fonctionne, mais sur le processeur. Le nouveau moteur est donc lancé à blanc
avant toute bascule ; s'il ne démarre pas, ou s'il ne voit plus aucune carte
alors que le moteur courant en voyait, la mise à jour est refusée et le moteur
courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intact :
**revenir au moteur de l'image** y ramène en un clic, sans réseau.
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
## Licence
MIT — © les contributeurs d'AJEAN (« Jean contributors ») pour le code amont,
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

+34 -3
View File
@@ -12,10 +12,41 @@ import (
"strings"
)
// readAPIKeyErr renvoie la clé Bearer des complétions en distinguant « aucune
// clé » d'une LECTURE RATÉE — même nuance que readWebKeyErr, et pour la même
// raison : sans clé, l'endpoint est OUVERT. Confondre les deux, c'est ouvrir
// l'accès aux complétions parce que la base était momentanément verrouillée par
// une commande CLI. requireCompletionKey refuse donc plutôt que d'ouvrir.
func readAPIKeyErr() (string, error) {
b, err := getBytesErr(bkState, "api_key")
return string(b), err
}
// readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est
// définie. Elle est rangée hors de la configuration pour survivre aux
// changements de preset, qui remplacent la configuration en bloc.
func readAPIKey() string { return getStr(bkState, "api_key") }
// définie (ou illisible). Elle est rangée hors de la configuration pour survivre
// aux changements de preset, qui remplacent la configuration en bloc. Réservé à
// l'affichage et aux appels internes ; toute décision d'accès passe par
// effectiveAPIKeyErr.
func readAPIKey() string { k, _ := readAPIKeyErr(); return k }
// effectiveAPIKeyErr renvoie la clé que llama-server exige RÉELLEMENT : celle
// rangée en base, sinon la clé résiduelle de config.env (repli rétro-compatible,
// cf. backend_serve.go).
//
// Une seule source pour les deux côtés du relais. Quand la garde de Loki ne
// regardait que la clé en base, un API_KEY oublié dans config.env donnait un
// endpoint « ouvert » côté Loki et un 401 côté moteur — le client voyait un
// refus sans jamais comprendre quelle clé fournir.
func effectiveAPIKeyErr() (string, error) {
k, err := readAPIKeyErr()
if err != nil {
return "", err
}
if k != "" {
return k, nil
}
return strings.TrimSpace(ReadConfig()["API_KEY"]), nil
}
// authHeader sets the Authorization: Bearer header on req when an API key is
// configured, so Loki's own internal calls (chat/web/bench/test) authenticate
+650
View File
@@ -0,0 +1,650 @@
// backend_engine_oci.go — mise à jour du moteur llama.cpp SANS reconstruire
// l'image et sans compiler : on va chercher le moteur là où l'équipe llama.cpp
// le publie déjà tout compilé — dans son image de conteneur officielle — et on
// en extrait le seul dossier qui nous intéresse, /app.
//
// Pourquoi ce détour plutôt que les releases GitHub (backend_prebuilt.go) :
// llama.cpp ne publie AUCUN binaire CUDA pour Linux dans ses releases. La seule
// distribution CUDA/Linux officielle et précompilée, ce sont ces images
// (ghcr.io/ggml-org/llama.cpp:server-cuda-b#####). C'est aussi exactement ce
// dont l'image de Loki hérite : mettre à jour par ce chemin, c'est obtenir le
// même moteur qu'une reconstruction complète de l'image, en ~170 Mo au lieu de
// 2,6 Go et sans passer par un rebuild.
//
// Ce qu'on télécharge : PAS l'image. Un manifeste OCI liste ses couches ; le
// runtime CUDA (2 Go) et les paquets système sont déjà présents dans l'image de
// Loki, donc inutiles. Seules les couches du haut portent /app (llama-server et
// ses .so, ~170 Mo). On les prend en partant du sommet et on s'arrête dès qu'on
// tient un moteur complet.
//
// Ce que ça NE remplace PAS : le runtime CUDA, lui, vient de l'image. Un
// llama.cpp compilé pour un CUDA majeur plus récent que celui de l'image ne
// démarrera pas — d'où le test à blanc (--list-devices) avant toute bascule, et
// la possibilité de revenir au moteur d'origine en un clic.
package loki
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
// ociBase est une variable et non une constante pour que les tests puissent la
// pointer sur un faux registre : tout ce qui suit se joue dans le dialogue avec
// le registre, et le vérifier en frappant ghcr.io rendrait la CI dépendante du
// réseau.
// LOKI_OCI_REGISTRY le remplace : c'est la porte de sortie pour un réseau qui
// n'atteint pas ghcr.io directement — miroir d'entreprise, cache local — sans
// quoi la mise à jour du moteur y serait tout simplement impossible.
var ociBase = "https://ghcr.io"
func init() {
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("LOKI_OCI_REGISTRY")), "/"); v != "" {
if !strings.Contains(v, "://") {
v = "https://" + v
}
ociBase = v
}
}
// ociHost : l'hôte seul, tel que l'attend le paramètre `service` du jeton.
func ociHost() string {
h := strings.TrimPrefix(strings.TrimPrefix(ociBase, "https://"), "http://")
return strings.TrimSuffix(h, "/")
}
const (
ociEngineRepo = "ggml-org/llama.cpp"
// Au-delà de cette taille, une couche n'est plus une couche « applicative » :
// c'est le runtime CUDA (2 Go) ou une base système, qu'on ne veut à aucun
// prix télécharger. On descend les couches du sommet vers la base ; croiser
// une couche de cette taille signifie qu'on a dépassé /app.
ociLayerCap = 600 << 20
)
// ociLayer : une couche du manifeste (on n'a besoin que de ça).
type ociLayer struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// ociDoc couvre à la fois un index multi-architecture et un manifeste d'image :
// les deux arrivent sur la même URL, distingués par le champ qui est rempli.
type ociDoc struct {
MediaType string `json:"mediaType"`
Manifests []struct {
Digest string `json:"digest"`
Platform struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
} `json:"platform"`
} `json:"manifests"`
Layers []ociLayer `json:"layers"`
Annotations map[string]string `json:"annotations"`
}
// ociAccept : tous les types de manifeste qu'on sait lire. Sans cet en-tête, le
// registre répond en schéma 1 (obsolète) et la lecture échoue sans expliquer.
const ociAccept = "application/vnd.oci.image.index.v1+json," +
"application/vnd.docker.distribution.manifest.list.v2+json," +
"application/vnd.oci.image.manifest.v1+json," +
"application/vnd.docker.distribution.manifest.v2+json"
// ociToken obtient un jeton de lecture ANONYME. Les images publiques de ghcr.io
// sont lisibles sans compte, mais le registre exige quand même un jeton porteur.
func ociToken(repo string) (string, error) {
u := fmt.Sprintf("%s/token?scope=repository:%s:pull&service=%s", ociBase, repo, ociHost())
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(u)
if err != nil {
return "", fmt.Errorf("registre %s injoignable : %w", ociHost(), err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("%s/token : HTTP %d", ociHost(), resp.StatusCode)
}
var out struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Token == "" {
return "", fmt.Errorf("jeton de lecture illisible")
}
return out.Token, nil
}
// ociFetch fait un GET authentifié sur le registre.
func ociFetch(url, token, accept string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if accept != "" {
req.Header.Set("Accept", accept)
}
return (&http.Client{Timeout: 0}).Do(req)
}
// ociArch traduit GOARCH vers le nom de plateforme OCI.
func ociArch() string {
if runtime.GOARCH == "arm64" {
return "arm64"
}
return "amd64"
}
// ociResolve lit le manifeste d'un tag et renvoie les couches de l'image pour
// CETTE architecture, plus le numéro de build annoncé par l'image
// (org.opencontainers.image.version, ex. « b10450 »).
func ociResolve(repo, tag string) (layers []ociLayer, version string, err error) {
token, err := ociToken(repo)
if err != nil {
return nil, "", err
}
get := func(ref string) (*ociDoc, error) {
resp, err := ociFetch(fmt.Sprintf("%s/v2/%s/manifests/%s", ociBase, repo, ref), token, ociAccept)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("version inconnue dans le registre : %s", ref)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("manifeste %s : HTTP %d", ref, resp.StatusCode)
}
var d ociDoc
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("manifeste %s illisible : %w", ref, err)
}
return &d, nil
}
doc, err := get(tag)
if err != nil {
return nil, "", err
}
version = doc.Annotations["org.opencontainers.image.version"]
if len(doc.Manifests) > 0 {
want := ociArch()
digest := ""
for _, m := range doc.Manifests {
if m.Platform.OS == "linux" && m.Platform.Architecture == want {
digest = m.Digest
break
}
}
if digest == "" {
return nil, "", fmt.Errorf("aucune image linux/%s pour %s", want, tag)
}
if doc, err = get(digest); err != nil {
return nil, "", err
}
if v := doc.Annotations["org.opencontainers.image.version"]; v != "" {
version = v
}
}
if len(doc.Layers) == 0 {
return nil, "", fmt.Errorf("manifeste %s sans couche", tag)
}
return doc.Layers, version, nil
}
// ---------------------------------------------------------------------------
// Variante, version et emplacement du moteur
// ---------------------------------------------------------------------------
// engineOCISupported : ce mécanisme n'a de sens que là où ces images tournent.
// Les images llama.cpp sont linux/amd64 et linux/arm64, rien d'autre.
func engineOCISupported() bool {
return runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
}
// engineVariant déduit la variante d'image correspondant au moteur COURANT, en
// regardant les backends ggml posés à côté du binaire. C'est la seule source
// fiable : le conteneur ne sait pas de quelle image il a été bâti, et demander
// à l'utilisateur de retenir « server-cuda » serait un piège à erreur.
//
// Renvoie un préfixe de tag : "server-cuda", "server-vulkan", "server-intel",
// "server-musa", ou "server" (CPU) par défaut.
func engineVariant() string {
dir := ""
if bin := currentEngineBin(); bin != "" {
dir = filepath.Dir(bin)
}
if dir == "" {
return "server"
}
has := func(prefix string) bool {
m, _ := filepath.Glob(filepath.Join(dir, prefix+"*"))
return len(m) > 0
}
switch {
case has("libggml-cuda.so"):
return "server-cuda"
case has("libggml-musa.so"):
return "server-musa"
case has("libggml-sycl.so"):
return "server-intel"
case has("libggml-vulkan.so"):
return "server-vulkan"
}
return "server"
}
// currentEngineBin : le binaire réellement utilisé (BIN de la configuration,
// avec le repli sur le moteur de l'image quand BIN n'est pas encore posé).
func currentEngineBin() string {
bin := prebuiltResolveBin(ReadConfig()["BIN"])
if bin != "" && isFile(bin) {
return bin
}
return providedEngineBin()
}
// La bannière de version de llama.cpp a changé de forme plusieurs fois, et un
// binaire ancien reste parfaitement utilisable : on lit les trois formes.
//
// version: 0.1.0-dev (build 10450, commit ece963f41) ← actuelle
// version: 10450 (ece963f) ← précédente
// build: 4589 (a1b2c3d) ← plus ancienne
//
// C'est le NUMÉRO DE BUILD qui compte : c'est lui qui figure dans les tags
// d'image (server-cuda-b10450) et donc la seule chose comparable entre ce qui
// tourne et ce qui est publié.
var reEngineBuildLong = regexp.MustCompile(`build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)`)
var reEngineBuildShort = regexp.MustCompile(`(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)`)
func parseEngineBuild(out string) (build int, commit string) {
for _, re := range []*regexp.Regexp{reEngineBuildLong, reEngineBuildShort} {
if m := re.FindStringSubmatch(out); m != nil {
n, _ := strconv.Atoi(m[1])
return n, m[2]
}
}
return 0, ""
}
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
// d'en installer un autre.
func engineBuildOf(bin string) (int, string) {
if bin == "" || !isFile(bin) {
return 0, ""
}
cmd := hideCmd(exec.Command(bin, "--version"))
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
return parseEngineBuild(string(out))
}
// engineDir : racine des moteurs installés par ce mécanisme, sous LOKI_HOME —
// c'est-à-dire sur le volume /data en conteneur, donc conservés d'un
// redémarrage (et d'une mise à jour d'image) à l'autre.
func engineDir() string { return filepath.Join(LokiHome(), "engine") }
// engineOwns dit si un chemin désigne un moteur installé ici.
func engineOwns(p string) bool {
if p == "" {
return false
}
norm := func(s string) string { return strings.ToLower(filepath.ToSlash(filepath.Clean(s))) }
return strings.HasPrefix(norm(p), norm(engineDir())+"/")
}
// engineInstalled liste les moteurs présents sous engineDir(), du plus ancien
// au plus récent (ordre des numéros de build).
func engineInstalled() []string {
entries, err := os.ReadDir(engineDir())
if err != nil {
return nil
}
var tags []string
for _, e := range entries {
if e.IsDir() && isFile(filepath.Join(engineDir(), e.Name(), "llama-server")) {
tags = append(tags, e.Name())
}
}
sort.Slice(tags, func(i, j int) bool { return engineTagBuild(tags[i]) < engineTagBuild(tags[j]) })
return tags
}
var reTagBuild = regexp.MustCompile(`-b(\d+)$`)
// engineTagBuild extrait le numéro de build d'un tag (« server-cuda-b10450 » →
// 10450), 0 si le tag n'en porte pas (tag mouvant « server-cuda »).
func engineTagBuild(tag string) int {
m := reTagBuild.FindStringSubmatch(tag)
if m == nil {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
}
// engineTagOf : le tag d'un moteur téléchargé ici (« server-cuda-b10450 »), ""
// pour tout autre chemin.
func engineTagOf(bin string) string {
if !engineOwns(bin) {
return ""
}
return filepath.Base(filepath.Dir(bin))
}
// engineBinFor : chemin du llama-server d'une version installée.
func engineBinFor(tag string) string {
p := filepath.Join(engineDir(), filepath.Base(tag), "llama-server")
if isFile(p) {
return p
}
return ""
}
// enginePrune supprime les versions installées autres que celles à garder.
// Chaque version pèse ~450 Mo une fois décompressée : en laisser traîner cinq
// remplit le volume /data sans rien apporter.
func enginePrune(keep map[string]bool, logf func(string)) {
for _, tag := range engineInstalled() {
if keep[tag] {
continue
}
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err == nil && logf != nil {
logf("ancienne version supprimée : " + tag)
}
}
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
// engineOCIInstall télécharge le moteur du tag demandé et l'installe dans
// engineDir()/<tag>. Renvoie le chemin du llama-server installé.
//
// Réentrant : une version déjà installée est renvoyée telle quelle, et une
// installation interrompue laisse un dossier « .part » qui est repris de zéro.
func engineOCIInstall(tag string, logf, phasef func(string)) (string, error) {
tag = filepath.Base(strings.TrimSpace(tag)) // jamais de séparateur dans un nom de dossier
if tag == "" || tag == "." {
return "", fmt.Errorf("version du moteur non précisée")
}
if bin := engineBinFor(tag); bin != "" {
logf("déjà installé : " + tag)
return bin, nil
}
phasef("lecture du manifeste " + tag + "…")
layers, version, err := ociResolve(ociEngineRepo, tag)
if err != nil {
return "", err
}
if version != "" {
logf(fmt.Sprintf("image %s:%s — llama.cpp %s, %d couches", ociEngineRepo, tag, version, len(layers)))
}
dest := filepath.Join(engineDir(), tag)
tmp := dest + ".part"
if err := os.RemoveAll(tmp); err != nil {
return "", err
}
if err := os.MkdirAll(tmp, 0o755); err != nil {
return "", err
}
defer os.RemoveAll(tmp) // no-op après le rename final
token, err := ociToken(ociEngineRepo)
if err != nil {
return "", err
}
// On descend du SOMMET vers la base : /app est ajouté en dernier dans le
// Dockerfile amont, et les couches du bas sont le runtime CUDA et le système,
// déjà présents ici. Une couche du haut peut masquer un fichier d'une couche
// plus basse : le premier extrait gagne, comme le ferait un runtime OCI.
seen := map[string]bool{}
var links []archiveLink
for i := len(layers) - 1; i >= 0; i-- {
l := layers[i]
if l.Size > ociLayerCap {
logf(fmt.Sprintf("couche %d ignorée (%d Mo) : c'est le runtime de l'image, pas le moteur", i, l.Size/1_000_000))
break
}
part := filepath.Join(tmp, "layer.tgz")
if l.Size > 4<<20 {
phasef(fmt.Sprintf("téléchargement du moteur (%d Mo)…", l.Size/1_000_000))
}
if err := ociDownloadBlob(ociEngineRepo, l.Digest, token, part, l.Size, logf); err != nil {
return "", fmt.Errorf("téléchargement de la couche %d : %w", i, err)
}
n, err := extractAppLayer(part, tmp, seen, &links)
_ = os.Remove(part)
if err != nil {
return "", fmt.Errorf("extraction de la couche %d : %w", i, err)
}
if n > 0 {
logf(fmt.Sprintf("couche %d : %d fichiers du moteur", i, n))
}
if engineSetComplete(seen) {
break
}
}
if err := applyLinks(links); err != nil {
return "", err
}
if !seen["llama-server"] {
return "", fmt.Errorf("llama-server absent de l'image %s — variante inattendue ?", tag)
}
if err := os.RemoveAll(dest); err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", err
}
if err := os.Rename(tmp, dest); err != nil {
return "", err
}
bin := filepath.Join(dest, "llama-server")
if err := os.Chmod(bin, 0o755); err != nil {
return "", err
}
logf("moteur installé : " + bin)
return bin, nil
}
// engineSetComplete : a-t-on de quoi lancer le moteur ? Le binaire seul ne
// suffit pas — depuis que llama.cpp éclate son code en bibliothèques, l'exécutable
// tient dans une couche et ses .so dans la précédente. S'arrêter au binaire
// donnait un moteur qui meurt sur « libllama.so: cannot open shared object ».
func engineSetComplete(seen map[string]bool) bool {
if !seen["llama-server"] {
return false
}
base, llama := false, false
for name := range seen {
switch {
case strings.HasPrefix(name, "libggml-base.so"):
base = true
case strings.HasPrefix(name, "libllama.so"):
llama = true
}
}
return base && llama
}
// ociDownloadBlob télécharge une couche vers dest, avec la progression du
// téléchargement dans le journal du job.
func ociDownloadBlob(repo, digest, token, dest string, size int64, logf func(string)) error {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/%s/blobs/%s", ociBase, repo, digest), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
return downloadRequest(req, dest, size, logf)
}
// extractAppLayer extrait les entrées « app/… » d'une couche (tar.gz) à PLAT
// dans dir, en ignorant celles déjà vues (une couche supérieure fait foi) et
// les marqueurs de suppression OCI (.wh.*). Renvoie le nombre de fichiers
// extraits. Les liens sont accumulés dans links : leur cible n'est pas
// forcément déjà extraite au moment où on croise l'entrée.
func extractAppLayer(archive, dir string, seen map[string]bool, links *[]archiveLink) (int, error) {
f, err := os.Open(archive)
if err != nil {
return 0, err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, err
}
defer gz.Close()
count := 0
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
return count, nil
}
if err != nil {
return count, err
}
name, ok := appEntryName(h.Name)
if !ok || seen[name] {
continue
}
p := filepath.Join(dir, name)
switch h.Typeflag {
case tar.TypeReg:
w, err := os.Create(p)
if err != nil {
return count, err
}
if _, err := io.Copy(w, tr); err != nil {
w.Close()
return count, err
}
w.Close()
_ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
seen[name] = true
count++
case tar.TypeSymlink, tar.TypeLink:
// Indispensable : les .so sont livrés sous leur nom versionné
// (libllama.so.0.1.0) plus deux liens (libllama.so.0, libllama.so) —
// c'est le nom court que l'éditeur de liens cherche au lancement.
target, ok := appEntryName(h.Linkname)
if !ok {
target = filepath.Base(h.Linkname) // lien relatif au sein de /app
}
*links = append(*links, archiveLink{path: p, target: target})
seen[name] = true
count++
}
}
}
// appEntryName ramène « app/libllama.so » à « libllama.so ». Renvoie ok=false
// pour tout ce qui n'est pas un fichier direct de /app : autres dossiers,
// sous-dossiers, et marqueurs de suppression OCI.
func appEntryName(raw string) (string, bool) {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
rest, ok := strings.CutPrefix(p, "app/")
if !ok || rest == "" || strings.Contains(rest, "/") {
return "", false
}
if strings.HasPrefix(rest, ".wh.") {
return "", false
}
return rest, true
}
// ---------------------------------------------------------------------------
// Vérification et bascule
// ---------------------------------------------------------------------------
// engineOCILatest renvoie le dernier build publié pour la variante (numéro et
// tag figé correspondant). On lit le tag MOUVANT (« server-cuda ») et son
// annotation de version plutôt que de lister les 1000 tags du dépôt : une seule
// requête, et c'est l'amont qui dit ce qu'est « la dernière ».
func engineOCILatest(variant string) (build int, tag string, err error) {
_, version, err := ociResolve(ociEngineRepo, variant)
if err != nil {
return 0, "", err
}
if version == "" {
// Pas d'annotation : on retombe sur le tag mouvant lui-même, qui pointe
// bien sur la dernière image — on ne saura juste pas dire son numéro.
return 0, variant, nil
}
// L'annotation vaut « b10450 » : le tag figé est « server-cuda-b10450 ».
n, _ := strconv.Atoi(strings.TrimPrefix(version, "b"))
return n, variant + "-" + version, nil
}
// engineSmokeTest lance à blanc le moteur fraîchement installé — c'est là que
// se joue le seul vrai risque de ce mécanisme. La mise à jour apporte llama.cpp,
// PAS le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
// CUDA plus récent ne chargera pas son backend GPU ici.
//
// Deux échecs distincts, et le second est le sournois :
//
// 1. le binaire ne démarre pas du tout (bibliothèque manquante) — bruyant ;
// 2. il démarre mais ne voit plus AUCUNE carte, alors que le moteur courant en
// voyait. Rien ne casse : le modèle se charge, tourne sur le processeur, et
// on ne s'en aperçoit qu'au débit. C'est le pire résultat possible, donc on
// le traite comme un échec de mise à jour.
//
// Ne voir aucune carte n'est un échec que si le moteur courant en voyait :
// sur une machine sans GPU, « aucune carte » est le résultat normal.
func engineSmokeTest(bin, ancien string) error {
devs, err := engineListDevices(bin)
if err != nil {
return err
}
if len(devs) > 0 {
return nil
}
if ancien == "" || ancien == bin {
return nil
}
avant, err := engineListDevices(ancien)
if err != nil || len(avant) == 0 {
return nil // le moteur courant n'en voyait pas plus : rien à reprocher au nouveau
}
return fmt.Errorf("le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit %d — "+
"il tournerait sur le processeur, beaucoup plus lentement", len(avant))
}
// engineListDevices lance `llama-server --list-devices` sur un binaire donné.
func engineListDevices(bin string) ([]map[string]any, error) {
cmd := hideCmd(exec.Command(bin, "--list-devices"))
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("le moteur ne démarre pas ici : %v\n%s", err, tailLines(string(out), 12))
}
return parseListDevices(string(out)), nil
}
// tailLines garde les n dernières lignes non vides d'une sortie.
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
@@ -0,0 +1,53 @@
package loki
// Vérification contre le VRAI registre. Ignoré par défaut (la CI ne doit pas
// dépendre du réseau ni télécharger 170 Mo) :
//
// LOKI_NET_TEST=1 go test ./internal/loki -run TestMoteurReelDepuisGhcr -v
//
// Ce test existe parce que le faux registre ne peut pas garantir une chose : que
// la DISPOSITION de l'image amont est toujours celle qu'on suppose. Si llama.cpp
// range un jour son moteur autrement — sous-dossier, couche gigantesque, autre
// jeu de bibliothèques — c'est ici qu'on le verra, et pas chez l'utilisateur.
import (
"os"
"path/filepath"
"testing"
)
func TestMoteurReelDepuisGhcr(t *testing.T) {
if os.Getenv("LOKI_NET_TEST") == "" {
t.Skip("test réseau — LOKI_NET_TEST=1 pour l'exécuter")
}
if os.Getenv("LOKI_HOME") == "" {
t.Setenv("LOKI_HOME", t.TempDir())
}
build, tag, err := engineOCILatest("server-cuda")
if err != nil {
t.Fatal(err)
}
t.Logf("dernier build publié : %d → %s", build, tag)
if build == 0 {
t.Error("numéro de build absent de l'annotation de l'image")
}
bin, err := engineOCIInstall(tag, func(s string) { t.Log(s) }, func(s string) { t.Log("▶ " + s) })
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(bin)
// Ce que l'éditeur de liens réclamera au lancement. Un seul manquant et le
// moteur meurt sur « cannot open shared object file ».
for _, n := range []string{"libllama.so", "libggml.so", "libggml-base.so", "libggml-cuda.so", "libmtmd.so"} {
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
t.Errorf("%s : %v", n, err)
}
}
// Et le binaire doit savoir dire sa version : c'est ce qui pilote la
// comparaison « installé vs publié » de tout le panneau.
if got, commit := engineBuildOf(bin); got != build {
t.Errorf("le moteur installé annonce le build %d (%s), attendu %d", got, commit, build)
}
}
+390
View File
@@ -0,0 +1,390 @@
package loki
// Mise à jour du moteur depuis l'image officielle llama.cpp.
//
// Ce qui se joue ici n'est pas « est-ce qu'on télécharge » mais « est-ce qu'on
// télécharge le MINIMUM et est-ce que ce qu'on pose est LANÇABLE ». Deux pièges
// concrets, tous deux invisibles sans test :
//
// - l'image officielle range llama-server dans une couche et ses .so dans la
// précédente ; s'arrêter au binaire donne un moteur qui meurt sur
// « libllama.so: cannot open shared object file » ;
// - la couche du dessous est le runtime CUDA (2 Go). La descendre par erreur,
// c'est transformer une mise à jour de 170 Mo en un téléchargement de 2 Go.
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// --- Faux registre OCI ------------------------------------------------------
// fauxCouche fabrique une couche tar.gz. Les entrées sont décrites par leur nom
// complet dans l'archive ; une valeur préfixée par « -> » crée un lien.
func fauxCouche(t *testing.T, entrees map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for nom, contenu := range entrees {
if cible, ok := strings.CutPrefix(contenu, "-> "); ok {
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeSymlink, Linkname: cible, Mode: 0o777}); err != nil {
t.Fatal(err)
}
continue
}
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeReg, Size: int64(len(contenu)), Mode: 0o755}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(contenu)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// fauxRegistre sert un index multi-architecture, le manifeste amd64 et les
// blobs. `tailles` permet d'annoncer une taille différente de la taille réelle
// (c'est ce que lit le garde-fou des grosses couches). Renvoie le compteur des
// blobs réellement téléchargés.
func fauxRegistre(t *testing.T, version string, couches [][]byte, tailles []int64) (servis map[string]int) {
t.Helper()
servis = map[string]int{}
digests := make([]string, len(couches))
blobs := map[string][]byte{}
for i, c := range couches {
sum := sha256.Sum256(c)
digests[i] = "sha256:" + hex.EncodeToString(sum[:])
blobs[digests[i]] = c
}
manifeste := map[string]any{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"layers": func() []map[string]any {
var out []map[string]any
for i := range couches {
taille := int64(len(couches[i]))
if tailles != nil && tailles[i] > 0 {
taille = tailles[i]
}
out = append(out, map[string]any{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": digests[i], "size": taille,
})
}
return out
}(),
}
manBytes, _ := json.Marshal(manifeste)
manSum := sha256.Sum256(manBytes)
manDigest := "sha256:" + hex.EncodeToString(manSum[:])
index := map[string]any{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": []map[string]any{
// L'architecture qui ne nous concerne pas vient EN PREMIER : choisir
// « le premier manifeste » au lieu de filtrer sur la plateforme
// installerait un moteur d'une autre architecture.
{"digest": "sha256:mauvaise", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}},
{"digest": manDigest, "platform": map[string]string{"os": "linux", "architecture": ociArch()}},
},
"annotations": map[string]string{"org.opencontainers.image.version": version},
}
indexBytes, _ := json.Marshal(index)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/token"):
_, _ = w.Write([]byte(`{"token":"jeton-de-test"}`))
case strings.Contains(r.URL.Path, "/manifests/"):
if r.Header.Get("Authorization") != "Bearer jeton-de-test" {
w.WriteHeader(401)
return
}
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
if ref == manDigest {
_, _ = w.Write(manBytes)
return
}
_, _ = w.Write(indexBytes)
case strings.Contains(r.URL.Path, "/blobs/"):
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
b, ok := blobs[ref]
if !ok {
w.WriteHeader(404)
return
}
servis[ref]++
_, _ = w.Write(b)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(srv.Close)
ancien := ociBase
ociBase = srv.URL
t.Cleanup(func() { ociBase = ancien })
return servis
}
// --- Tests ------------------------------------------------------------------
// Le cas réel : trois couches utiles au-dessus d'un runtime de 2 Go. On doit
// prendre le binaire ET les bibliothèques, résoudre les liens de version, et
// ne JAMAIS descendre jusqu'au runtime.
func TestInstallationMoteurDepuisImage(t *testing.T) {
testHome(t)
couches := [][]byte{
fauxCouche(t, map[string]string{"usr/local/cuda/lib64/libcudart.so": "runtime CUDA"}), // 2 Go annoncés
fauxCouche(t, map[string]string{
"app/libggml-base.so": "-> libggml-base.so.0",
"app/libggml-base.so.0": "-> libggml-base.so.0.20.0",
"app/libggml-base.so.0.20.0": "binaire ELF",
"app/libllama.so": "-> libllama.so.0",
"app/libllama.so.0": "-> libllama.so.0.1.0",
"app/libllama.so.0.1.0": "binaire ELF",
"app/libggml-cuda.so": "binaire ELF",
}),
fauxCouche(t, map[string]string{"app/llama-server": "#!/bin/true\n", "app/llama": "#!/bin/true\n"}),
fauxCouche(t, map[string]string{"etc/passwd": "sans rapport"}),
}
servis := fauxRegistre(t, "b10450", couches, []int64{2 << 30, 0, 0, 0})
bin, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {})
if err != nil {
t.Fatal(err)
}
if veut := filepath.Join(engineDir(), "server-cuda-b10450", "llama-server"); bin != veut {
t.Errorf("binaire installé en %s, attendu %s", bin, veut)
}
// La couche du runtime CUDA ne doit pas avoir été demandée : c'est tout
// l'intérêt (170 Mo au lieu de 2,6 Go).
sum := sha256.Sum256(couches[0])
if n := servis["sha256:"+hex.EncodeToString(sum[:])]; n != 0 {
t.Errorf("la couche du runtime CUDA a été téléchargée %d fois", n)
}
dir := filepath.Dir(bin)
for _, nom := range []string{"llama-server", "llama", "libggml-cuda.so", "libggml-base.so.0.20.0", "libllama.so.0.1.0"} {
if !isFile(filepath.Join(dir, nom)) {
t.Errorf("%s manquant", nom)
}
}
// Les noms courts sont ceux que l'éditeur de liens cherche au lancement :
// sans eux le moteur ne démarre pas, alors que tout « a l'air » installé.
for _, lien := range []string{"libggml-base.so", "libggml-base.so.0", "libllama.so", "libllama.so.0"} {
p := filepath.Join(dir, lien)
if _, err := os.Stat(p); err != nil { // Stat suit le lien : vérifie aussi qu'il ne pend pas
t.Errorf("%s : %v", lien, err)
}
}
// /app à plat, et rien d'autre que /app.
if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) {
t.Error("des fichiers hors /app ont été extraits")
}
if st, err := os.Stat(bin); err != nil || st.Mode()&0o100 == 0 {
t.Errorf("llama-server non exécutable : %v", err)
}
// Réinstaller la même version ne redemande RIEN au registre : le job de mise
// à jour repasse par là à chaque vérification.
total := func() int {
n := 0
for _, v := range servis {
n += v
}
return n
}
avant := total()
if _, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {}); err != nil {
t.Fatal(err)
}
if total() != avant {
t.Errorf("une version déjà installée a été re-téléchargée (%d → %d couches)", avant, total())
}
// Et la version se retrouve dans la liste des moteurs installés.
if got := engineInstalled(); len(got) != 1 || got[0] != "server-cuda-b10450" {
t.Errorf("versions installées = %v", got)
}
if engineTagBuild("server-cuda-b10450") != 10450 {
t.Error("numéro de build mal extrait du tag")
}
if !engineOwns(bin) {
t.Error("le binaire installé n'est pas reconnu comme géré par Loki")
}
}
// Une variante dont l'image ne contient pas de llama-server (mauvais tag, image
// « full » sans serveur) doit échouer CLAIREMENT, et ne rien laisser derrière
// elle qui ressemblerait à un moteur installé.
func TestInstallationRefuseUneImageSansMoteur(t *testing.T) {
testHome(t)
fauxRegistre(t, "b10450", [][]byte{
fauxCouche(t, map[string]string{"app/README": "pas de moteur ici"}),
}, nil)
if _, err := engineOCIInstall("server-b1", func(string) {}, func(string) {}); err == nil {
t.Fatal("installation acceptée sans llama-server")
}
if got := engineInstalled(); len(got) != 0 {
t.Errorf("versions installées après échec = %v", got)
}
}
// engineOCILatest lit le numéro de build dans l'annotation de l'image, et en
// déduit le tag figé — c'est lui qu'on installe, pour que « mis à jour vers »
// désigne une version précise et reproductible plutôt qu'un tag mouvant.
func TestDerniereVersionPubliee(t *testing.T) {
fauxRegistre(t, "b10450", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
build, tag, err := engineOCILatest("server-cuda")
if err != nil {
t.Fatal(err)
}
if build != 10450 || tag != "server-cuda-b10450" {
t.Errorf("build=%d tag=%q", build, tag)
}
}
// La bannière du moteur est la seule source de vérité sur la version qui tourne
// vraiment — le tag de l'image, lui, ment dès qu'on a basculé de moteur.
func TestLectureVersionMoteur(t *testing.T) {
for _, c := range []struct {
nom, sortie string
build int
commit string
}{
// Relevée sur le binaire réel de l'image server-cuda-b10450.
{"forme actuelle", "version: 0.1.0-dev (build 10450, commit ece963f41)\nbuilt with GNU 14.2.0 for Linux x86_64\n", 10450, "ece963f41"},
{"forme précédente", "version: 10450 (ece963f)\nbuilt with cc\n", 10450, "ece963f"},
{"forme ancienne", "build: 4589 (a1b2c3d)\n", 4589, "a1b2c3d"},
{"bannière de démarrage", "ggml_cuda_init: found 1 device\nversion: 9001 (deadbee)\n", 9001, "deadbee"},
{"rien d'exploitable", "usage: llama-server [options]\n", 0, ""},
} {
t.Run(c.nom, func(t *testing.T) {
build, commit := parseEngineBuild(c.sortie)
if build != c.build || commit != c.commit {
t.Errorf("= (%d, %q), attendu (%d, %q)", build, commit, c.build, c.commit)
}
})
}
}
// Le filtre des entrées d'archive : on ne veut QUE les fichiers directs de /app.
// Un sous-dossier ou un marqueur de suppression OCI recopié à plat produirait
// des fichiers parasites à côté du binaire.
func TestFiltreDesEntreesApp(t *testing.T) {
for _, c := range []struct {
entree, veut string
ok bool
}{
{"app/llama-server", "llama-server", true},
{"./app/libllama.so", "libllama.so", true},
{"app/", "", false},
{"app/tools/quantize", "", false},
{"app/.wh.libggml-cuda.so", "", false},
{"usr/local/cuda/lib64/libcudart.so", "", false},
{"apparence/piege", "", false},
} {
got, ok := appEntryName(c.entree)
if ok != c.ok || got != c.veut {
t.Errorf("%q → (%q, %v), attendu (%q, %v)", c.entree, got, ok, c.veut, c.ok)
}
}
}
// Le binaire seul ne fait pas un moteur : c'est la règle d'arrêt de la descente
// des couches.
func TestMoteurCompletExigeLesBibliotheques(t *testing.T) {
vu := func(noms ...string) map[string]bool {
m := map[string]bool{}
for _, n := range noms {
m[n] = true
}
return m
}
if engineSetComplete(vu("llama-server")) {
t.Error("binaire seul accepté comme moteur complet")
}
if engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0")) {
t.Error("moteur sans libllama accepté")
}
if !engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0", "libllama.so.0.1.0")) {
t.Error("moteur complet refusé")
}
}
// La variante se déduit des backends posés à côté du binaire : c'est ce qui
// évite de proposer une image CUDA à une installation Vulkan (et l'inverse).
func TestVarianteDeduiteDesBackends(t *testing.T) {
for _, c := range []struct{ backend, veut string }{
{"libggml-cuda.so", "server-cuda"},
{"libggml-vulkan.so", "server-vulkan"},
{"libggml-sycl.so", "server-intel"},
{"libggml-musa.so", "server-musa"},
{"", "server"},
} {
t.Run(c.veut, func(t *testing.T) {
home := testHome(t)
dir := filepath.Join(home, "faux-moteur")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "llama-server")
if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if c.backend != "" {
if err := os.WriteFile(filepath.Join(dir, c.backend), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := SetConfigKey("BIN", bin); err != nil {
t.Fatal(err)
}
if got := engineVariant(); got != c.veut {
t.Errorf("variante = %q, attendu %q", got, c.veut)
}
})
}
}
// Le ménage garde la version demandée et efface les autres : sans lui, chaque
// mise à jour laisse ~450 Mo sur le volume de données.
func TestMenageDesAnciennesVersions(t *testing.T) {
testHome(t)
for _, tag := range []string{"server-cuda-b1", "server-cuda-b2", "server-cuda-b3"} {
d := filepath.Join(engineDir(), tag)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
}
enginePrune(map[string]bool{"server-cuda-b3": true}, nil)
got := engineInstalled()
if fmt.Sprint(got) != "[server-cuda-b3]" {
t.Errorf("après ménage : %v", got)
}
}
+28
View File
@@ -70,6 +70,34 @@ func reasoningActive(v string) bool {
return true
}
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
// "" (auto) n'y figure pas : c'est l'absence de réglage.
var ReasoningEfforts = []string{"none", "low", "medium", "high"}
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
//
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur —
// c'est pourquoi il n'y a pas de repli à prévoir ici, contrairement à un backend
// qui rejetterait la requête. En pratique seuls les modèles dont le gabarit
// gère `reasoning_effort` (gpt-oss et apparentés) changent de comportement.
func reasoningEffortValue(v string) string {
switch s := strings.ToLower(strings.TrimSpace(v)); s {
case "", "auto", "default":
return "" // rien envoyé : le gabarit garde son comportement
case "off", "false", "0", "no", "disable", "disabled", "none":
return "none"
case "low", "medium", "high":
return s
default:
// Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse
// passer au gabarit, qui l'ignorera s'il ne la connaît pas.
return s
}
}
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
// (manual override, with or without a leading '#') wins; otherwise it is
// auto-detected from the MODEL= filename. Returns "" when unknown.
+11 -1
View File
@@ -440,8 +440,18 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) {
// downloadWithProgress télécharge url vers dest en journalisant la progression
// par tranches de ~25 Mo.
func downloadWithProgress(url, dest string, total int64, logf func(string)) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
return downloadRequest(req, dest, total, logf)
}
// downloadRequest est la même chose pour une requête déjà construite — un
// registre OCI exige un en-tête d'autorisation, même en lecture anonyme.
func downloadRequest(req *http.Request, dest string, total int64, logf func(string)) error {
client := &http.Client{Timeout: 0}
resp, err := client.Get(url)
resp, err := client.Do(req)
if err != nil {
return err
}
+1 -3
View File
@@ -176,9 +176,7 @@ func cmdServe(args []string) error {
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
if k := readAPIKey(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
} else if k := cfg["API_KEY"]; k != "" {
if k, _ := effectiveAPIKeyErr(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
}
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
+5 -3
View File
@@ -557,9 +557,11 @@ func (c *Conversation) Stop() {
// le bump d'epoch réduit de toute façon au silence.
func (c *Conversation) Reset() {
c.Stop()
// Vider la discussion efface aussi ses captures : les messages qui les
// affichaient disparaissent, les fichiers n'auraient plus aucun lecteur.
dropConvCaptures(convEnsureActive())
// Vider la discussion efface aussi ses fichiers (dépôts, captures, ce que
// l'agent y a écrit) : les messages qui les mentionnaient disparaissent, plus
// rien ne les rattacherait à quoi que ce soit. L'UI le dit avant de demander
// confirmation.
dropConvFiles(convEnsureActive())
c.mu.Lock()
c.Messages = nil
c.Log = nil
+304
View File
@@ -0,0 +1,304 @@
package loki
// chat_convfiles.go — les fichiers rangés PAR DISCUSSION.
//
// Avant, tout ce qui passait par le chat — pièces jointes déposées, rapports
// écrits par l'agent, captures de pages — atterrissait dans UN seul dossier de
// travail partagé par toutes les discussions. Changer de discussion ne changeait
// rien au panneau Fichiers (on revoyait les mêmes), et supprimer une discussion
// laissait ses fichiers derrière elle — seules ses captures, déjà rangées par
// identifiant, partaient avec.
//
// Désormais chaque discussion a son dossier :
//
// <workspace>/discussions/<id>/ ← dossier courant de l'agent
// <workspace>/discussions/<id>/uploads/ ← pièces jointes de CETTE discussion
// <workspace>/discussions/<id>/captures/ ← captures de CETTE discussion
//
// Supprimer une discussion (ou la vider) emporte tout le dossier, et le panneau
// Fichiers n'ouvre que celui de la discussion active.
//
// La racine du dossier de travail reste la BORNE de sécurité : les liens écrits
// dans les anciens messages (`uploads/x.pdf`, `captures/<id>/y.jpg`) pointent
// encore vers elle et doivent continuer de marcher — c'est le rôle de
// workspaceFile, qui cherche d'abord dans la discussion puis retombe sur les
// emplacements d'avant.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"unicode"
)
const (
// convFilesRoot : le sous-dossier qui contient un dossier par discussion.
// Nommé, plutôt que d'éparpiller les identifiants à la racine, pour que le
// dossier de travail reste lisible quand on l'ouvre depuis l'hôte.
convFilesRoot = "discussions"
// uploadsSub : les dépôts de l'utilisateur, à l'intérieur de la discussion.
uploadsSub = "uploads"
)
// safeConvID refuse tout identifiant qui ne soit pas un simple nom : c'est lui
// qui devient un segment de chemin, et il arrive parfois du client (paramètre de
// requête, ancien lien de message). Renvoie "" si l'identifiant est douteux.
func safeConvID(id string) string {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return ""
}
for _, r := range id {
if r == '-' || r == '_' || unicode.IsDigit(r) || (r < unicode.MaxASCII && unicode.IsLetter(r)) {
continue
}
return ""
}
return id
}
// convDirFor renvoie le dossier (absolu) d'une discussion, sans le créer. Un
// identifiant vide ou douteux retombe sur la racine du dossier de travail :
// mieux vaut écrire au mauvais endroit que refuser d'écrire.
func convDirFor(convID string) string {
id := safeConvID(convID)
if id == "" {
return agentWorkspace()
}
return filepath.Join(agentWorkspace(), convFilesRoot, id)
}
// convWorkspace est le dossier de travail COURANT : celui de la discussion
// active, créé au besoin. C'est là que résolvent les chemins relatifs du modèle
// (resolveAgentPath), que démarre son shell, et que se déposent les fichiers.
//
// Si la création échoue (disque plein, droits), on retombe sur la racine du
// dossier de travail : l'agent doit continuer d'écrire quelque part.
func convWorkspace() string {
dir := convDirFor(convEnsureActive())
if dir == agentWorkspace() {
return dir
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return agentWorkspace()
}
return dir
}
// dropConvFiles efface TOUT le dossier d'une discussion (dépôts, captures,
// fichiers écrits par l'agent). Appelé quand la discussion est supprimée ou
// vidée : plus un message ne les mentionne, personne ne les retrouverait.
// Best-effort — un échec ne doit rien interrompre.
func dropConvFiles(convID string) {
dir := convDirFor(convID)
if dir == agentWorkspace() {
return // identifiant douteux : surtout pas la racine
}
_ = os.RemoveAll(dir)
}
// relWithin dit si `abs` se trouve DANS `root` et, si oui, renvoie son chemin
// relatif en séparateurs '/'.
//
// EvalSymlinks des deux côtés : sans ça, un lien posé dans le dossier passerait
// le test de préfixe tout en pointant ailleurs sur le disque.
func relWithin(root, abs string) (string, bool) {
if root == "" {
return "", false
}
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if a, err := filepath.EvalSymlinks(abs); err == nil {
abs = a
}
rel, err := filepath.Rel(root, abs)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", false
}
return filepath.ToSlash(rel), true
}
// workspaceFile résout un chemin RELATIF venu d'un client ou d'un message
// (`uploads/rapport.pdf`, `captures/xxx.jpg`) en fichier du disque.
//
// Ordre de recherche :
// 1. le dossier de la discussion active — le cas normal ;
// 2. les emplacements d'AVANT le rangement par discussion, pour que les liens
// des anciens messages continuent d'ouvrir leur fichier.
//
// ok=false signale une sortie du périmètre (« .. »), pas une absence : un
// fichier introuvable revient avec le chemin attendu, pour que l'appelant
// réponde « introuvable » et non « hors du dossier de travail ».
func workspaceFile(rel string) (string, bool) {
rel = strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"), "/")
if rel == "" {
return "", false
}
base := convWorkspace()
p := filepath.Join(base, filepath.FromSlash(rel))
if _, ok := relWithin(base, p); !ok {
return "", false
}
if _, err := os.Stat(p); err == nil {
return p, true
}
if legacy, ok := legacyWorkspaceFile(rel); ok {
return legacy, true
}
return p, true
}
// legacyWorkspaceFile retrouve un fichier désigné par un chemin d'avant le
// rangement par discussion. Deux formes existaient :
//
// captures/<id>/x.jpg → discussions/<id>/captures/x.jpg (déplacé par la migration)
// uploads/x.pdf → resté à la racine si aucune discussion ne le réclamait
func legacyWorkspaceFile(rel string) (string, bool) {
if parts := strings.SplitN(rel, "/", 3); len(parts) == 3 && parts[0] == captureDir && safeConvID(parts[1]) != "" {
p := filepath.Join(convDirFor(parts[1]), captureDir, filepath.FromSlash(parts[2]))
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, true
}
}
p := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(p); !ok {
return "", false
}
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, true
}
return "", false
}
// filesRoot donne la racine du panneau Fichiers pour un `scope` de requête :
// la discussion active par défaut, la racine du dossier de travail pour
// `scope=legacy` — celle où dorment les fichiers d'avant le rangement par
// discussion, que la migration n'a pas su rattacher.
func filesRoot(scope string) (root string, legacy bool) {
if strings.TrimSpace(scope) == "legacy" {
return agentWorkspace(), true
}
return convWorkspace(), false
}
// legacyCount compte ce qui traîne à la racine du dossier de travail, hors
// dossier des discussions. L'UI n'affiche le raccourci « hors discussion » que
// si ce compte est non nul — il tombe à zéro une fois le ménage fait, et le
// bouton disparaît de lui-même.
func legacyCount() int {
ents, err := os.ReadDir(agentWorkspace())
if err != nil {
return 0
}
n := 0
for _, e := range ents {
if e.Name() == convFilesRoot || strings.HasPrefix(e.Name(), ".") {
continue
}
n++
}
return n
}
var convFilesMigrateOnce sync.Once
// migrateConvFiles range les fichiers des versions précédentes dans le dossier
// de leur discussion. Une seule fois par process, au démarrage du serveur web.
func migrateConvFiles() { convFilesMigrateOnce.Do(migrateConvFilesNow) }
func migrateConvFilesNow() {
root := agentWorkspace()
if root == "" {
return
}
// 1. Les captures étaient DÉJÀ par discussion : captures/<id> → discussions/<id>/captures.
// Un simple renommage, donc instantané quel que soit le nombre d'images.
oldCaps := filepath.Join(root, captureDir)
if ents, err := os.ReadDir(oldCaps); err == nil {
for _, e := range ents {
id := safeConvID(e.Name())
if !e.IsDir() || id == "" {
continue
}
dst := filepath.Join(convDirFor(id), captureDir)
if _, err := os.Stat(dst); err == nil {
continue // déjà rangé (migration interrompue puis reprise)
}
if os.MkdirAll(filepath.Dir(dst), 0o755) != nil {
continue
}
_ = os.Rename(filepath.Join(oldCaps, e.Name()), dst)
}
_ = os.Remove(oldCaps) // ne part que s'il est vide
}
// 2. Les dépôts, eux, étaient dans un pot commun. On rend chaque fichier à la
// discussion qui le mentionne — l'historique garde le nom des pièces
// jointes de chaque tour. Ce qui n'est réclamé par personne reste à la
// racine : toujours téléchargeable (legacyWorkspaceFile) et visible via
// « hors discussion », plutôt que rattaché au hasard.
migrateLegacyUploads(filepath.Join(root, uploadsSub))
}
func migrateLegacyUploads(src string) {
ents, err := os.ReadDir(src)
if err != nil {
return
}
left := map[string]bool{}
for _, e := range ents {
if !e.IsDir() {
left[e.Name()] = true
}
}
for _, m := range convIndex() {
if len(left) == 0 {
break
}
for _, name := range convAttachNames(m.ID) {
if !left[name] {
continue
}
dst := filepath.Join(convDirFor(m.ID), uploadsSub)
if os.MkdirAll(dst, 0o755) != nil {
continue
}
if os.Rename(filepath.Join(src, name), filepath.Join(dst, name)) == nil {
delete(left, name)
}
}
}
_ = os.Remove(src) // ne part que s'il est vide
}
// convAttachNames relit les pièces jointes annoncées dans le journal d'affichage
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
// relie un fichier déposé à sa discussion.
func convAttachNames(id string) []string {
b := getBytes(bkChat, convKey(id))
if len(b) == 0 {
return nil
}
var st struct {
Log []struct {
Delta struct {
Files []attachInfo `json:"files"`
} `json:"delta"`
} `json:"log"`
}
if json.Unmarshal(b, &st) != nil {
return nil
}
var out []string
for _, ev := range st.Log {
for _, f := range ev.Delta.Files {
if n := safeUploadName(f.Name); n != "" {
out = append(out, n)
}
}
}
return out
}
+243
View File
@@ -0,0 +1,243 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// Le cœur de la demande : deux discussions ne partagent pas leurs fichiers.
// Avant, uploads/ était commun — on retrouvait les mêmes pièces jointes partout.
func TestFichiersIsolesParDiscussion(t *testing.T) {
withWorkspace(t)
a := convEnsureActive()
dirA, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dirA, "note-a.txt"), "aaa")
b := convNew() // b devient active
if a == b {
t.Fatal("convNew n'a pas créé de nouvelle discussion")
}
dirB, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
if dirA == dirB {
t.Fatalf("les deux discussions déposent au même endroit : %s", dirA)
}
writeFile(t, filepath.Join(dirB, "note-b.txt"), "bbb")
// La discussion ouverte ne voit QUE ses propres dépôts.
names := listedNames(t, "uploads")
if names["note-a.txt"] || !names["note-b.txt"] {
t.Fatalf("la liste de la discussion b montre %v", names)
}
// Et le message ne peut pas joindre le fichier de l'autre discussion.
if got := attachFiles([]string{"uploads/note-a.txt"}); len(got) != 0 {
t.Fatalf("pièce jointe d'une autre discussion retenue : %+v", got)
}
if got := attachFiles([]string{"uploads/note-b.txt"}); len(got) != 1 {
t.Fatalf("pièce jointe de la discussion courante refusée : %+v", got)
}
// Retour sur a : ses fichiers sont là, ceux de b ont disparu de la vue.
if err := convSwitch(a); err != nil {
t.Fatal(err)
}
names = listedNames(t, "uploads")
if !names["note-a.txt"] || names["note-b.txt"] {
t.Fatalf("la liste de la discussion a montre %v", names)
}
}
// Supprimer une discussion emporte TOUS ses fichiers, pas seulement ses
// captures — et surtout pas ceux des autres.
func TestConvDeleteSupprimeLesFichiers(t *testing.T) {
ws := withWorkspace(t)
a := convEnsureActive()
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
writeFile(t, filepath.Join(ws, "rapport.md"), "rapport")
b := convNew()
autre := convWorkspace()
writeFile(t, filepath.Join(autre, "garde-moi.txt"), "ok")
if err := convDelete(a); err != nil {
t.Fatalf("suppression : %v", err)
}
if _, err := os.Stat(ws); !os.IsNotExist(err) {
t.Fatalf("le dossier de la discussion supprimée est toujours là (%v)", err)
}
if _, err := os.Stat(filepath.Join(autre, "garde-moi.txt")); err != nil {
t.Fatalf("les fichiers d'une AUTRE discussion ont été supprimés : %v", err)
}
if convEnsureActive() != b {
t.Fatalf("discussion active = %q, attendu %q", convEnsureActive(), b)
}
}
// Vider une discussion (« clear chat ») efface aussi ses fichiers : les messages
// qui les mentionnaient partent avec.
func TestResetSupprimeLesFichiersDeLaDiscussion(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
conv.Reset()
if _, err := os.Stat(filepath.Join(ws, "uploads", "joint.pdf")); !os.IsNotExist(err) {
t.Fatalf("le fichier de la discussion vidée a survécu (%v)", err)
}
}
// Un identifiant de discussion douteux ne doit JAMAIS devenir un segment de
// chemin : dropConvFiles effacerait alors la racine du dossier de travail.
func TestDropConvFilesRefuseUnIdDouteux(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "a.txt"), "a")
racine := agentWorkspace()
for _, bad := range []string{"", ".", "..", "../..", "c1/../..", "/etc", `..\..`} {
dropConvFiles(bad)
if _, err := os.Stat(racine); err != nil {
t.Fatalf("id %q a détruit le dossier de travail : %v", bad, err)
}
}
if _, err := os.Stat(filepath.Join(ws, "a.txt")); err != nil {
t.Fatalf("fichier de la discussion détruit : %v", err)
}
}
// Les liens des anciens messages doivent continuer d'ouvrir leur fichier : ils
// pointent vers uploads/ et captures/<id>/ à la RACINE du dossier de travail.
func TestTelechargementDesCheminsDAvant(t *testing.T) {
withWorkspace(t)
racine := agentWorkspace()
id := convEnsureActive()
writeFile(t, filepath.Join(racine, "uploads", "ancien.txt"), "avant")
// Capture d'avant, déplacée par la migration là où elle est désormais rangée.
writeFile(t, filepath.Join(convDirFor(id), captureDir, "vue.jpg"), "jpeg")
for path, want := range map[string]string{
"uploads/ancien.txt": "avant",
captureDir + "/" + id + "/vue.jpg": "jpeg",
captureDir + "/vue.jpg": "jpeg", // le chemin d'aujourd'hui
} {
rec := httptest.NewRecorder()
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(path), nil))
if rec.Code != 200 || rec.Body.String() != want {
t.Errorf("path=%q : code %d, corps %q", path, rec.Code, rec.Body.String())
}
}
}
// La migration range les fichiers des versions précédentes : les captures par
// leur identifiant, les dépôts par la discussion qui les mentionne. Ce que
// personne ne réclame reste à la racine, atteignable par « hors discussion ».
func TestMigrationRangeLesFichiersDAvant(t *testing.T) {
withWorkspace(t)
racine := agentWorkspace()
a := convEnsureActive()
// Un tour avec pièce jointe : c'est cette trace, dans le journal de la
// discussion, qui rattache le fichier à elle.
state, _ := json.Marshal(map[string]any{
"log": []map[string]any{{"seq": 1, "delta": map[string]any{
"user": "regarde",
"files": []attachInfo{{Name: "joint.pdf", Path: "uploads/joint.pdf", Size: 3}},
}}},
})
if err := putBytes(bkChat, convKey(a), state); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(racine, "uploads", "joint.pdf"), "pdf")
writeFile(t, filepath.Join(racine, "uploads", "orphelin.bin"), "??")
writeFile(t, filepath.Join(racine, captureDir, a, "vue.jpg"), "jpeg")
// La migration ne joue qu'une fois par process : on rearme le verrou pour
// pouvoir l'observer ici.
convFilesMigrateOnce = sync.Once{}
migrateConvFiles()
if _, err := os.Stat(filepath.Join(convDirFor(a), uploadsSub, "joint.pdf")); err != nil {
t.Errorf("le dépôt n'a pas rejoint sa discussion : %v", err)
}
if _, err := os.Stat(filepath.Join(convDirFor(a), captureDir, "vue.jpg")); err != nil {
t.Errorf("la capture n'a pas rejoint sa discussion : %v", err)
}
// Le fichier que personne ne réclame reste lisible plutôt que rattaché au hasard.
if _, err := os.Stat(filepath.Join(racine, uploadsSub, "orphelin.bin")); err != nil {
t.Errorf("le dépôt orphelin a été perdu : %v", err)
}
if legacyCount() == 0 {
t.Error("le reliquat hors discussion n'est pas signalé à l'UI")
}
}
// La vue « hors discussion » ne doit pas devenir une porte dérobée vers les
// fichiers des autres discussions.
func TestScopeLegacyNeDonnePasLesDiscussions(t *testing.T) {
ws := withWorkspace(t)
racine := agentWorkspace()
writeFile(t, filepath.Join(ws, "secret.txt"), "x")
writeFile(t, filepath.Join(racine, "ancien.txt"), "y")
out := listScope(t, "", "legacy")
if out["ok"] != true {
t.Fatalf("liste refusée : %v", out)
}
for _, e := range out["entries"].([]any) {
if e.(map[string]any)["name"] == convFilesRoot {
t.Fatalf("le dossier des discussions est listé : %v", out)
}
}
// Et il ne se supprime pas d'un clic : ce serait toutes les discussions.
rec := httptest.NewRecorder()
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
strings.NewReader(`{"path":`+jsonQuote(convFilesRoot)+`,"scope":"legacy"}`)))
if rec.Code == http.StatusOK {
t.Errorf("suppression du dossier des discussions acceptée : %s", rec.Body.String())
}
if _, err := os.Stat(filepath.Join(ws, "secret.txt")); err != nil {
t.Fatalf("fichier d'une discussion supprimé : %v", err)
}
}
// listedNames renvoie les noms listés dans un dossier du panneau.
func listedNames(t *testing.T, dir string) map[string]bool {
t.Helper()
out := listFiles(t, dir)
names := map[string]bool{}
if out["ok"] != true {
return names
}
for _, e := range out["entries"].([]any) {
names[e.(map[string]any)["name"].(string)] = true
}
return names
}
func listScope(t *testing.T, dir, scope string) map[string]any {
t.Helper()
rec := httptest.NewRecorder()
handleChatFiles(rec, httptest.NewRequest("GET",
"/api/chat/files?dir="+url.QueryEscape(dir)+"&scope="+url.QueryEscape(scope), nil))
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
}
out["_code"] = rec.Code
return out
}
+16 -17
View File
@@ -30,28 +30,24 @@ import (
"time"
)
// captureDir : sous-dossier du workspace où atterrissent les captures. Elles
// sont rangées PAR DISCUSSION (captures/<id>/…) pour que supprimer une
// discussion supprime aussi ses images — sinon elles s'accumulaient sur le
// disque sans qu'aucun écran ne les mentionne plus.
// captureDir : sous-dossier où atterrissent les captures, À L'INTÉRIEUR du
// dossier de la discussion (discussions/<id>/captures/…) — comme les dépôts.
// Supprimer une discussion emporte donc ses images, qui sinon s'accumulaient
// sur le disque sans qu'aucun écran ne les mentionne plus.
const captureDir = "captures"
// captureDirFor renvoie le dossier de captures d'une discussion (chemin absolu)
// et son préfixe relatif, celui qui sert dans les URLs d'affichage.
// et son préfixe relatif, celui qui sert dans les URLs d'affichage. Le relatif
// part du dossier de la DISCUSSION : /api/chat/image résout d'abord là
// (workspaceFile), et les liens des anciens messages — en captures/<id>/… —
// continuent d'ouvrir leur image par le chemin de repli.
func captureDirFor(convID string) (abs, rel string) {
rel = captureDir + "/" + convID
return filepath.Join(agentWorkspace(), captureDir, convID), rel
return filepath.Join(convDirFor(convID), captureDir), captureDir
}
// dropConvCaptures supprime les captures d'une discussion. Appelé quand on la
// supprime ou qu'on la vide. Best-effort : un échec ne doit rien interrompre.
func dropConvCaptures(convID string) {
if convID == "" {
return
}
abs, _ := captureDirFor(convID)
_ = os.RemoveAll(abs)
}
// Supprimer les captures d'une discussion n'a plus de fonction à soi : elles
// vivent dans son dossier, et dropConvFiles (chat_convfiles.go) l'emporte en
// entier — dépôts et fichiers écrits par l'agent compris.
// screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a
// son propre délai interne, celui-ci est le garde-fou externe.
@@ -188,7 +184,10 @@ func screenshotImageMessage(relPath string) (Message, bool) {
if !visionEnabled() || !engineSeesImages() {
return Message{}, false
}
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath))
abs, ok := workspaceFile(relPath)
if !ok {
return Message{}, false
}
mime := imageMime(abs)
if mime == "" {
return Message{}, false
+8 -3
View File
@@ -11,6 +11,10 @@ package loki
// bkChat/active → identifiant de la discussion ouverte
// bkChat/conv:<id> → l'état complet d'une discussion (JSON de Conversation)
//
// Les FICHIERS suivent le même découpage, sur le disque cette fois :
// <workspace>/discussions/<id>/ (chat_convfiles.go). Supprimer une discussion
// supprime donc aussi ses fichiers.
//
// Basculer = enregistrer la discussion courante, charger l'autre en mémoire et
// incrémenter l'epoch : les abonnés SSE reçoivent alors {reset:true} et
// rejouent le nouveau fil depuis zéro. C'est le mécanisme déjà utilisé par
@@ -228,9 +232,10 @@ func convDelete(id string) error {
}
convIndexSave(next)
_ = putBytes(bkChat, convKey(id), nil)
// Les captures de cette discussion n'ont plus rien qui les référence : les
// garder occuperait le disque pour toujours.
dropConvCaptures(id)
// Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
dropConvFiles(id)
if id != getStr(bkChat, ckActive) {
return nil
}
+7 -6
View File
@@ -149,7 +149,7 @@ func machineSystemPrompt(caps Caps) string {
if u, err := user.Current(); err == nil {
who = u.Username
}
cwd := agentWorkspace()
cwd := convWorkspace()
var b strings.Builder
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
@@ -185,16 +185,17 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
// Le shell démarre dans le workspace, pas dans le dossier d'où loki a été
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
// Le shell démarre dans le dossier de la discussion ouverte, pas dans le
// dossier d'où loki a été lancé : un `> notes.txt` du modèle ne doit pas
// atterrir sur le Bureau, ni dans une AUTRE discussion.
//
// Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même le
// La racine est résolue UNE fois par process (agentWorkspace), donc si elle
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même la
// supprime — toutes les commandes suivantes échouaient sur un « chdir : no
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
// tout refuser.
if ws := agentWorkspace(); ws != "" {
if ws := convWorkspace(); ws != "" {
if err := os.MkdirAll(ws, 0o755); err == nil {
cmd.Dir = ws
}
+8 -2
View File
@@ -17,6 +17,11 @@ import (
// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS
// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
// dossier précis, ça doit marcher.
//
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
// qu'un fichier appartienne à la discussion où il est né et disparaisse avec
// elle. La racine reste la borne de sécurité des téléchargements.
const workspaceEnv = "LOKI_WORKSPACE"
@@ -69,7 +74,8 @@ func workspaceCandidates() []string {
}
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
// "~/x" → dans le home de l'utilisateur ; relatif → dans le workspace.
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
// discussion ouverte.
func resolveAgentPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
@@ -83,5 +89,5 @@ func resolveAgentPath(p string) string {
if filepath.IsAbs(p) {
return p
}
return filepath.Join(agentWorkspace(), filepath.FromSlash(p))
return filepath.Join(convWorkspace(), filepath.FromSlash(p))
}
+11 -5
View File
@@ -6,14 +6,19 @@ import (
"testing"
)
// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le
// répertoire courant du processus (Bureau, C:\ProgramData\loki\bin…).
// Un chemin relatif du modèle doit atterrir dans le dossier de la DISCUSSION
// ouverte, jamais dans le répertoire courant du processus (Bureau,
// C:\ProgramData\loki\bin…) ni dans un dossier commun à toutes les discussions.
func TestResolveAgentPathRelative(t *testing.T) {
ws := withWorkspace(t)
got := resolveAgentPath("meteo.json")
want := filepath.Join(agentWorkspace(), "meteo.json")
want := filepath.Join(ws, "meteo.json")
if got != want {
t.Fatalf("resolveAgentPath = %q, attendu %q", got, want)
}
if !strings.Contains(got, convFilesRoot) {
t.Fatalf("le fichier n'est pas rangé par discussion : %q", got)
}
}
// Un chemin absolu demandé explicitement reste intouché.
@@ -25,9 +30,10 @@ func TestResolveAgentPathAbsolute(t *testing.T) {
}
func TestResolveAgentPathSubdir(t *testing.T) {
ws := withWorkspace(t)
got := resolveAgentPath("notes/2026/a.txt")
if !strings.HasPrefix(got, agentWorkspace()) {
t.Fatalf("%q hors du workspace %q", got, agentWorkspace())
if !strings.HasPrefix(got, ws) {
t.Fatalf("%q hors du dossier de la discussion %q", got, ws)
}
if strings.Contains(got, "/") && filepath.Separator != '/' {
t.Fatalf("séparateurs non normalisés : %q", got)
+8 -1
View File
@@ -574,7 +574,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// reasoning is enabled we split that out ourselves so the UI's reasoning
// bubble works regardless of backend. The ik_llama.cpp fork already sends
// reasoning_content, in which case we leave content untouched.
reasoningOn := reasoningActive(ReadConfig()["REASONING"])
chatCfg := ReadConfig()
reasoningOn := reasoningActive(chatCfg["REASONING"])
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
// retry the same turn once with tools removed so the model answers in plain
// text from the tool results already gathered, instead of dying mid-chat.
@@ -611,6 +615,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
"stream_options": map[string]any{"include_usage": true},
}
if reasoningEffort != "" {
payload["reasoning_effort"] = reasoningEffort
}
if len(tools) > 0 && !disableTools {
payload["tools"] = tools
// The model sometimes emits parallel tool calls, which this llama.cpp
+67 -14
View File
@@ -40,15 +40,67 @@ import (
// oaiSuffix est le domaine sous lequel on autorise l'émission de certificats.
const oaiSuffix = ".oai.ajean.link"
// viaTunnelHeader marque une requête arrivée par le tunnel du relais (posé par
// withLocalAuth, qui l'efface d'abord — un client ne peut donc pas le forger).
// Il sert à garder la promesse du tunnel : hors du front TLS dédié, la surface
// des complétions y reste fermée tant que oaiPublicEnabled() est faux.
const viaTunnelHeader = "X-Loki-Via"
// sendOAIError répond au format d'erreur d'OpenAI. Les SDK officiels lisent
// body.error.message ; un {"error":"…"} plat leur fait afficher un message vide,
// et certains clients (LiteLLM, LangChain) échouent carrément au décodage.
func sendOAIError(w http.ResponseWriter, status int, msg, typ, code string) {
sendJSON(w, status, map[string]any{"error": map[string]any{
"message": msg, "type": typ, "param": nil, "code": code,
}})
}
// mountOAI branche la surface compatible OpenAI sur le mux de Loki, protégée par
// la clé des complétions (web_auth.go).
//
// C'est ce qui rend l'API joignable par le nom de domaine ou l'IP de
// L'INTERFACE, sans publier de second port : le moteur peut rester sur la boucle
// locale, et un reverse proxy devant Loki suffit pour le TLS. L'adresse annoncée
// auparavant (celle du moteur, port 8080) n'était joignable dans aucun
// déploiement en conteneur, où ce port n'est pas publié.
//
// ⚠️ On ne monte QUE /v1/. Surtout pas /health, /props, /metrics ni /slots — que
// oaiHandler autorise pour le tunnel : ils divulgueraient le modèle chargé, la
// taille de contexte et l'état des slots, sans authentification quand aucune clé
// n'est définie. Le filtre interne d'oaiHandler reste en seconde barrière.
func mountOAI(mux *http.ServeMux) {
mux.Handle("/v1/", requireCompletionKey(oaiHandler()))
}
// oaiHandler construit le reverse-proxy vers llama-server, restreint à la surface
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le SaaS envoie
// la vraie clé (.api_key), que llama-server valide lui-même (--api-key).
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le client
// envoie la vraie clé, que llama-server valide lui-même (--api-key). Elle est
// donc vérifiée deux fois — par Loki puis par le moteur — et c'est voulu : le
// moteur reste protégé même si on l'expose un jour en direct.
func oaiHandler() http.Handler {
llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
lp := httputil.NewSingleHostReverseProxy(llama)
lp.FlushInterval = -1 // streaming SSE des complétions
lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway)
lp := &httputil.ReverseProxy{
// FlushInterval négatif = on vide le tampon à chaque écriture : c'est ce
// qui fait arriver les tokens un par un chez le client au lieu d'un bloc
// en fin de génération.
// ⚠️ Ne JAMAIS envelopper le ResponseWriter sur ce chemin (compteur,
// journalisation…) : un wrapper qui n'implémente pas http.Flusher
// retransforme le flux en réponse bufferisée, sans autre symptôme qu'une
// attente inexplicable.
FlushInterval: -1,
// Rewrite (et non Director) : le port du moteur est relu À CHAQUE
// REQUÊTE. Capturé à la construction, il figeait l'ancienne valeur dès
// qu'on changeait PORT depuis l'interface, et le proxy visait dans le
// vide jusqu'au redémarrage de Loki. Rewrite n'hérite pas non plus des
// X-Forwarded-* entrants : un client ne peut pas les forger vers le
// moteur.
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
pr.Out.Host = pr.In.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
sendOAIError(w, http.StatusBadGateway,
"moteur injoignable : "+e.Error(), "api_error", "upstream_unavailable")
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
@@ -56,7 +108,8 @@ func oaiHandler() http.Handler {
lp.ServeHTTP(w, r)
return
}
http.Error(w, "not found (endpoint OpenAI: /v1/*)", http.StatusNotFound)
sendOAIError(w, http.StatusNotFound,
"chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found")
})
}
@@ -72,14 +125,14 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
return srv.Serve(tls.NewListener(rawLn, tlsCfg))
}
// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette
// machine. Piloté par l'UI et lu en direct → activable/coupable sans redémarrer
// le service de lien.
// oaiPublicEnabled indique si le front TLS du tunnel doit servir la surface
// OpenAI. Drapeau désormais en LECTURE SEULE : plus aucune interface ne
// l'allume, depuis que l'endpoint est servi par Loki sur son propre port (voir
// mountOAI). Il ne subsiste que pour les installations migrées depuis AJEAN dont
// le tunnel sert encore ce front — et pour son défaut, qui est le refus :
// demuxTunnelStream ferme le flux quand il est absent.
func oaiPublicEnabled() bool { return getBool(bkState, "oai_public") }
// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public.
func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) }
// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via
// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et
// répond elle-même aux challenges ACME. La clé privée est stockée dans
+251
View File
@@ -0,0 +1,251 @@
package loki
// Tests de la surface compatible OpenAI servie par Loki (mountOAI).
//
// Elle est joignable partout où l'interface l'est : c'est tout l'intérêt, et
// c'est aussi ce qui rend sa garde d'accès critique. Ces tests verrouillent les
// quatre promesses : la clé est exigée quand elle existe, l'amont n'est jamais
// appelé quand elle manque, l'en-tête du client arrive INTACT au moteur (qui la
// revalide), et rien d'autre que /v1 n'est monté.
import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// fauxMoteur monte un llama-server de comédie et pointe la configuration
// dessus : le proxy vise 127.0.0.1:LLMPort(), c'est donc tout ce qu'il faut.
// Renvoie le serveur et un pointeur sur les en-têtes de la dernière requête
// reçue (nil tant qu'il n'a rien reçu).
func fauxMoteur(t *testing.T, h http.HandlerFunc) (*httptest.Server, func() http.Header) {
t.Helper()
var vues http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vues = r.Header.Clone()
if h != nil {
h(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"loki"}]}`))
}))
t.Cleanup(srv.Close)
port := srv.URL[strings.LastIndexByte(srv.URL, ':')+1:]
if _, err := strconv.Atoi(port); err != nil {
t.Fatalf("port du faux moteur illisible : %q", srv.URL)
}
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
return srv, func() http.Header { return vues }
}
// appelOAI joue une requête à travers la garde + le proxy, sans monter tout le
// serveur web (newWebMux a des effets de bord : MCP, migrations, conversation).
func appelOAI(t *testing.T, méthode, chemin, clé string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(méthode, chemin, nil)
if clé != "" {
req.Header.Set("Authorization", "Bearer "+clé)
}
rec := httptest.NewRecorder()
requireCompletionKey(oaiHandler()).ServeHTTP(rec, req)
return rec
}
// erreurOAI décode le corps au format d'erreur d'OpenAI — celui que les SDK
// savent présenter à l'utilisateur.
func erreurOAI(t *testing.T, rec *httptest.ResponseRecorder) (message, code string) {
t.Helper()
var body struct {
Error struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("corps illisible (%d) : %s", rec.Code, rec.Body.String())
}
return body.Error.Message, body.Error.Code
}
// Sans clé configurée, l'endpoint est ouvert — comme llama-server sans
// --api-key. Le panneau et le démarrage le disent en rouge ; ici on vérifie
// seulement que le comportement est bien celui-là, et pas un refus silencieux.
func TestOAISansCleLaissePasser(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
rec := appelOAI(t, "GET", "/v1/models", "")
if rec.Code != 200 {
t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"loki"`) {
t.Errorf("réponse du moteur non relayée : %s", rec.Body.String())
}
if vues() == nil {
t.Error("le moteur n'a pas été appelé")
}
}
// La bonne clé passe, ET l'en-tête arrive INTACT au moteur : c'est le contrat.
// llama-server la valide lui aussi (--api-key), il reste donc protégé même si
// on l'expose un jour en direct.
func TestOAIBonneCleEtEnTeteRelayee(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
if err := writeAPIKey("sk-loki-test"); err != nil {
t.Fatal(err)
}
rec := appelOAI(t, "GET", "/v1/models", "sk-loki-test")
if rec.Code != 200 {
t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String())
}
if got := vues().Get("Authorization"); got != "Bearer sk-loki-test" {
t.Errorf("en-tête transmis au moteur = %q, attendu la clé du client", got)
}
}
// Mauvaise clé, et clé absente : 401 au format OpenAI, et le moteur n'est JAMAIS
// appelé — une garde qui laisse passer la requête avant de refuser ne protège
// rien.
func TestOAICleRefusee(t *testing.T) {
for _, cas := range []struct{ nom, envoyée string }{
{"mauvaise clé", "sk-loki-pasbonne"},
{"aucune clé", ""},
} {
t.Run(cas.nom, func(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
if err := writeAPIKey("sk-loki-test"); err != nil {
t.Fatal(err)
}
rec := appelOAI(t, "POST", "/v1/chat/completions", cas.envoyée)
if rec.Code != 401 {
t.Fatalf("code %d, attendu 401 — corps %s", rec.Code, rec.Body.String())
}
if rec.Header().Get("WWW-Authenticate") == "" {
t.Error("en-tête WWW-Authenticate absent")
}
msg, code := erreurOAI(t, rec)
if code != "invalid_api_key" || msg == "" {
t.Errorf("erreur = {code:%q, message:%q}", code, msg)
}
if vues() != nil {
t.Error("le moteur a été appelé malgré le refus")
}
})
}
}
// La clé résiduelle de config.env fait foi quand la base n'en a pas : c'est
// celle que le moteur exige (backend_serve.go). Sans ce miroir, Loki laissait
// passer et llama refusait — un 401 que rien n'expliquait.
func TestOAICleResiduelleDeConfig(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
if err := SetConfigKey("API_KEY", "sk-ancienne"); err != nil {
t.Fatal(err)
}
if rec := appelOAI(t, "GET", "/v1/models", ""); rec.Code != 401 {
t.Fatalf("sans clé : code %d, attendu 401", rec.Code)
}
if vues() != nil {
t.Fatal("le moteur a été appelé malgré le refus")
}
if rec := appelOAI(t, "GET", "/v1/models", "sk-ancienne"); rec.Code != 200 {
t.Fatalf("avec la clé de config.env : code %d, attendu 200", rec.Code)
}
}
// Un préflight CORS ne porte jamais d'Authorization. Le refuser casserait tout
// client tiers qui tourne dans un navigateur.
func TestOAIPreflightPasseSansCle(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
if err := writeAPIKey("sk-loki-test"); err != nil {
t.Fatal(err)
}
if rec := appelOAI(t, "OPTIONS", "/v1/chat/completions", ""); rec.Code == 401 {
t.Fatalf("préflight refusé : %s", rec.Body.String())
}
if vues() == nil {
t.Error("le préflight n'a pas atteint le moteur, qui répond ses en-têtes CORS")
}
}
// Seul /v1/ est monté. /metrics, /props et /slots divulgueraient le modèle
// chargé et l'état du moteur — sans clé, à qui joint l'adresse.
func TestOAIMonteSeulementV1(t *testing.T) {
testHome(t)
mux := http.NewServeMux()
mountOAI(mux)
témoin := false
mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { témoin = true })
for _, chemin := range []string{"/metrics", "/props", "/slots", "/api/ping", "/"} {
témoin = false
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", chemin, nil))
if !témoin {
t.Errorf("%s n'a pas été servi par le handler racine — il est passé à l'endpoint OpenAI", chemin)
}
}
// …et /v1/, lui, ne va PAS au handler racine.
témoin = false
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil))
if témoin {
t.Error("/v1/models a été servi par le handler racine")
}
}
// Le streaming doit arriver en morceaux. Si FlushInterval régresse, le premier
// événement n'arrive qu'à la fin de la génération — invisible en test unitaire
// classique, d'où ce test qui bloque l'amont entre deux écritures.
func TestOAIStreamingArriveEnMorceaux(t *testing.T) {
testHome(t)
débloque := make(chan struct{})
fauxMoteur(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fl, ok := w.(http.Flusher)
if !ok {
t.Error("le faux moteur ne sait pas vider son tampon")
return
}
_, _ = w.Write([]byte("data: un\n\n"))
fl.Flush()
<-débloque
_, _ = w.Write([]byte("data: deux\n\n"))
fl.Flush()
})
front := httptest.NewServer(requireCompletionKey(oaiHandler()))
defer front.Close()
defer close(débloque)
resp, err := http.Get(front.URL + "/v1/chat/completions")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
lu := make(chan string, 1)
go func() {
ligne, _ := bufio.NewReader(resp.Body).ReadString('\n')
lu <- ligne
}()
select {
case ligne := <-lu:
if !strings.Contains(ligne, "un") {
t.Fatalf("première ligne = %q", ligne)
}
case <-time.After(3 * time.Second):
t.Fatal("rien reçu avant la fin de la génération : le flux est bufferisé")
}
}
+157
View File
@@ -0,0 +1,157 @@
package loki
// Catalogue MCP embarqué.
//
// Ajouter un serveur MCP demandait jusqu'ici d'écrire soi-même la commande et
// ses arguments (`npx -y @scope/paquet …`) dans la modale. Le catalogue fournit
// une liste de serveurs connus, déjà renseignés : un clic préremplit la modale,
// l'utilisateur relit la commande et enregistre.
//
// Le catalogue est un JSON EMBARQUÉ dans le binaire (go:embed), pas un appel à
// un annuaire distant : cohérent avec un Loki qui tourne hors ligne, et rien de
// ce qui s'exécute sur la machine ne vient du réseau. Pour proposer un serveur
// de plus : éditer mcp_catalog.json et recompiler.
//
// SÉCURITÉ : le catalogue ne fait qu'AFFICHER une commande. Il n'installe ni
// n'exécute rien tout seul — c'est l'enregistrement du serveur par
// l'utilisateur, via la modale existante, qui décide. Même niveau de confiance
// que l'outil `bash` du mode agent (voir l'en-tête de mcp_config.go).
import (
_ "embed"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
)
//go:embed mcp_catalog.json
var mcpCatalogRaw []byte
// MCPCatalogEntry décrit un serveur proposé par le catalogue. Les champs de
// transport reprennent ceux de MCPServerConfig : l'UI peut les recopier tels
// quels dans la modale d'ajout.
type MCPCatalogEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
// Runtime : binaire nécessaire pour lancer le serveur ("node" → npx,
// "python" → uvx). Sert à signaler une entrée non installable ici.
Runtime string `json:"runtime,omitempty"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
// Secrets : variables d'environnement que l'utilisateur DOIT renseigner
// (jetons, clés d'API). L'UI les met en évidence ; elles sont volontairement
// vides dans le catalogue.
Secrets []string `json:"secrets,omitempty"`
// Renseignés à la lecture, pas dans le fichier.
Installed bool `json:"installed"`
RuntimeOK bool `json:"runtimeOk"`
}
type mcpCatalogFile struct {
Version int `json:"version"`
Servers []MCPCatalogEntry `json:"servers"`
}
var (
mcpCatalogOnce sync.Once
mcpCatalogParsed []MCPCatalogEntry
mcpCatalogParseEr error
)
// mcpCatalogEntries décode le JSON embarqué une seule fois. Le contenu est figé
// dans le binaire : inutile de le relire à chaque appel.
func mcpCatalogEntries() ([]MCPCatalogEntry, error) {
mcpCatalogOnce.Do(func() {
var f mcpCatalogFile
if err := json.Unmarshal(mcpCatalogRaw, &f); err != nil {
mcpCatalogParseEr = fmt.Errorf("catalogue MCP illisible : %w", err)
return
}
mcpCatalogParsed = f.Servers
})
return mcpCatalogParsed, mcpCatalogParseEr
}
// mcpRuntimeAvailable indique si le binaire qui lance ce type de serveur est
// présent. L'image conteneur embarque Node ; uv/uvx peut manquer.
func mcpRuntimeAvailable(runtime string) bool {
switch runtime {
case "node":
return lookPathOK("npx")
case "python":
return lookPathOK("uvx")
default:
return true
}
}
func lookPathOK(bin string) bool {
_, err := exec.LookPath(bin)
return err == nil
}
// MCPCatalog renvoie les entrées du catalogue, chemins résolus et marquées
// « déjà installée » (un serveur configuré porte le même nom que l'entrée).
func MCPCatalog() ([]MCPCatalogEntry, error) {
entries, err := mcpCatalogEntries()
if err != nil {
return nil, err
}
configured, err := LoadMCPConfig()
if err != nil {
// Une config MCP illisible ne doit pas vider le catalogue : on affiche
// les entrées sans savoir lesquelles sont déjà là.
configured = map[string]MCPServerConfig{}
}
out := make([]MCPCatalogEntry, 0, len(entries))
for _, e := range entries {
e.Args = expandCatalogSlice(e.Args)
e.Env = expandCatalogMap(e.Env)
_, e.Installed = configured[e.ID]
e.RuntimeOK = mcpRuntimeAvailable(e.Runtime)
out = append(out, e)
}
return out, nil
}
// expandCatalogToken remplace les jetons de chemin du catalogue. Seul
// {workspace} existe : c'est le dossier de travail de l'agent, le seul chemin
// qu'un serveur du catalogue a une raison légitime de recevoir par défaut.
func expandCatalogToken(s string) string {
if !strings.Contains(s, "{workspace}") {
return s
}
return strings.ReplaceAll(s, "{workspace}", workspaceDir())
}
func expandCatalogSlice(in []string) []string {
if len(in) == 0 {
return in
}
out := make([]string, len(in))
for i, s := range in {
out[i] = expandCatalogToken(s)
}
return out
}
func expandCatalogMap(in map[string]string) map[string]string {
if len(in) == 0 {
return in
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = expandCatalogToken(v)
}
return out
}
+262
View File
@@ -0,0 +1,262 @@
{
"version": 1,
"note": "Catalogue MCP embarqué (hors ligne, aucun annuaire distant). Éditable à la main : ajoute une entrée pour la proposer dans l'interface, puis relance le build. Le jeton {workspace} est remplacé par le dossier de travail de l'agent. Les entrées « uvx --with mcp<2 … » visent des serveurs publiés avant le SDK MCP 2.0 : sans cet épinglage, ils plantent au démarrage (ImportError McpError).",
"servers": [
{
"id": "filesystem",
"name": "Filesystem",
"category": "Fichiers",
"description": "Lecture, écriture et recherche de fichiers, limitées aux dossiers passés en argument.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "{workspace}"],
"env": {},
"secrets": []
},
{
"id": "git",
"name": "Git",
"category": "Code",
"description": "Historique, diff et statut d'un dépôt Git ; commits et branches.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
"runtime": "python",
"command": "uvx",
"args": ["mcp-server-git", "--repository", "{workspace}"],
"env": {},
"secrets": []
},
{
"id": "fetch",
"name": "Fetch",
"category": "Web",
"description": "Télécharge une URL et la convertit en Markdown pour le modèle.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "mcp-server-fetch"],
"env": {},
"secrets": []
},
{
"id": "memory",
"name": "Memory",
"category": "Agent",
"description": "Mémoire sous forme de graphe de connaissances (entités, relations, observations).",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"env": { "MEMORY_FILE_PATH": "{workspace}/mcp-memory.json" },
"secrets": []
},
{
"id": "sequential-thinking",
"name": "Sequential Thinking",
"category": "Agent",
"description": "Structure le raisonnement en étapes révisables. Utile aux petits modèles locaux.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
"env": {},
"secrets": []
},
{
"id": "time",
"name": "Time",
"category": "Utilitaires",
"description": "Heure courante et conversions de fuseaux horaires.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "mcp-server-time"],
"env": {},
"secrets": []
},
{
"id": "sqlite",
"name": "SQLite",
"category": "Données",
"description": "Interroge et modifie une base SQLite locale.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/sqlite",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "mcp-server-sqlite", "--db-path", "{workspace}/agent.db"],
"env": {},
"secrets": []
},
{
"id": "everything",
"name": "Everything (test)",
"category": "Utilitaires",
"description": "Serveur de référence exposant toutes les primitives MCP. Sert à vérifier l'intégration.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"],
"env": {},
"secrets": []
},
{
"id": "duckduckgo",
"name": "DuckDuckGo",
"category": "Web",
"description": "Recherche web via DuckDuckGo, sans clé d'API.",
"homepage": "https://pypi.org/project/duckduckgo-mcp-server/",
"runtime": "python",
"command": "uvx",
"args": ["duckduckgo-mcp-server"],
"env": {},
"secrets": []
},
{
"id": "playwright",
"name": "Playwright",
"category": "Web",
"description": "Pilote un navigateur (navigation, clics, formulaires) via l'arbre d'accessibilité.",
"homepage": "https://github.com/microsoft/playwright-mcp",
"runtime": "node",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--headless"],
"env": {},
"secrets": []
},
{
"id": "puppeteer",
"name": "Puppeteer",
"category": "Web",
"description": "Automatisation d'un Chrome sans interface (alternative à Playwright).",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/puppeteer",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
"env": {},
"secrets": []
},
{
"id": "context7",
"name": "Context7",
"category": "Code",
"description": "Documentation à jour des bibliothèques, injectée directement dans le contexte.",
"homepage": "https://github.com/upstash/context7",
"runtime": "node",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {},
"secrets": []
},
{
"id": "github",
"name": "GitHub",
"category": "Code",
"description": "Dépôts, issues et pull requests via l'API GitHub. Nécessite un jeton personnel.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" },
"secrets": ["GITHUB_PERSONAL_ACCESS_TOKEN"]
},
{
"id": "postgres",
"name": "PostgreSQL",
"category": "Données",
"description": "Requêtes SQL en lecture seule et inspection du schéma d'une base PostgreSQL.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres",
"runtime": "node",
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://utilisateur:motdepasse@hote:5432/base"
],
"env": {},
"secrets": []
},
{
"id": "docker",
"name": "Docker",
"category": "Infra",
"description": "Gère conteneurs et stacks compose. Nécessite l'accès au socket Docker.",
"homepage": "https://pypi.org/project/docker-mcp/",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "docker-mcp"],
"env": {},
"secrets": []
},
{
"id": "brave-search",
"name": "Brave Search",
"category": "Web",
"description": "Recherche web et locale via l'API Brave. Nécessite une clé d'API.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": { "BRAVE_API_KEY": "" },
"secrets": ["BRAVE_API_KEY"]
},
{
"id": "firecrawl",
"name": "Firecrawl",
"category": "Web",
"description": "Scraping et crawl de sites avec extraction structurée. Nécessite une clé d'API.",
"homepage": "https://github.com/firecrawl/firecrawl-mcp-server",
"runtime": "node",
"command": "npx",
"args": ["-y", "firecrawl-mcp"],
"env": { "FIRECRAWL_API_KEY": "" },
"secrets": ["FIRECRAWL_API_KEY"]
},
{
"id": "notion",
"name": "Notion",
"category": "Productivité",
"description": "Lecture et écriture de pages et bases Notion. Nécessite un jeton d'intégration.",
"homepage": "https://github.com/makenotion/notion-mcp-server",
"runtime": "node",
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": { "NOTION_TOKEN": "" },
"secrets": ["NOTION_TOKEN"]
},
{
"id": "slack",
"name": "Slack",
"category": "Productivité",
"description": "Lecture des canaux et envoi de messages Slack. Nécessite un jeton de bot.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": { "SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": "" },
"secrets": ["SLACK_BOT_TOKEN"]
},
{
"id": "google-maps",
"name": "Google Maps",
"category": "Utilitaires",
"description": "Géocodage, itinéraires et recherche de lieux. Nécessite une clé d'API.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/google-maps",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-google-maps"],
"env": { "GOOGLE_MAPS_API_KEY": "" },
"secrets": ["GOOGLE_MAPS_API_KEY"]
},
{
"id": "sentry",
"name": "Sentry",
"category": "Infra",
"description": "Récupère les issues et traces d'erreur d'un projet Sentry.",
"homepage": "https://pypi.org/project/mcp-server-sentry/",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "mcp-server-sentry", "--auth-token", "REMPLACE_MOI"],
"env": {},
"secrets": []
}
]
}
+81
View File
@@ -0,0 +1,81 @@
package loki
import (
"strings"
"testing"
)
// Le catalogue est embarqué : s'il ne décode pas, le binaire livré est cassé et
// le panneau MCP part vide. Ce test attrape une virgule en trop au build.
func TestMCPCatalogDecodes(t *testing.T) {
entries, err := mcpCatalogEntries()
if err != nil {
t.Fatalf("catalogue illisible : %v", err)
}
if len(entries) == 0 {
t.Fatal("catalogue vide")
}
seen := map[string]bool{}
for _, e := range entries {
if e.ID == "" || e.Name == "" || e.Command == "" {
t.Errorf("entrée incomplète : %+v", e)
}
if seen[e.ID] {
t.Errorf("id dupliqué : %s", e.ID)
}
seen[e.ID] = true
if e.Description == "" {
t.Errorf("%s : description manquante", e.ID)
}
// Un secret annoncé doit exister comme variable d'environnement, sinon
// l'UI met en avant un champ que l'utilisateur ne peut pas remplir.
for _, s := range e.Secrets {
if _, ok := e.Env[s]; !ok {
t.Errorf("%s : secret %q absent de env", e.ID, s)
}
}
}
}
// Les jetons {workspace} doivent disparaître : un serveur lancé avec un chemin
// littéral « {workspace} » démarre puis échoue à la première lecture.
func TestMCPCatalogExpandsWorkspaceToken(t *testing.T) {
entries, err := MCPCatalog()
if err != nil {
t.Fatalf("MCPCatalog : %v", err)
}
for _, e := range entries {
for _, a := range e.Args {
if strings.Contains(a, "{workspace}") {
t.Errorf("%s : jeton non résolu dans args : %s", e.ID, a)
}
}
for k, v := range e.Env {
if strings.Contains(v, "{workspace}") {
t.Errorf("%s : jeton non résolu dans env[%s] : %s", e.ID, k, v)
}
}
}
}
func TestReasoningEffortValue(t *testing.T) {
cases := map[string]string{
"": "",
" ": "",
"auto": "",
"default": "",
"off": "none",
"none": "none",
"false": "none",
"0": "none",
"low": "low",
"MEDIUM": "medium",
" High ": "high",
}
for in, want := range cases {
if got := reasoningEffortValue(in); got != want {
t.Errorf("reasoningEffortValue(%q) = %q, attendu %q", in, got, want)
}
}
}
+12
View File
@@ -541,6 +541,18 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
// à chaque requête de l'autre côté.
func withLocalAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ⚠️ Marquage AVANT tout : on efface d'abord, sinon un client forge
// l'en-tête et se fait passer pour du trafic de tunnel (ou l'inverse).
r.Header.Del(viaTunnelHeader)
r.Header.Set(viaTunnelHeader, "tunnel")
// La surface des complétions a SA clé (celle du moteur, fournie par le
// client). Y injecter la clé de PILOTAGE la ferait refuser par la garde,
// et surtout la relaierait à llama-server — un secret d'administration
// envoyé à un process qui n'en a aucun usage.
if strings.HasPrefix(r.URL.Path, "/v1/") {
next.ServeHTTP(w, r)
return
}
if webKey := readWebKey(); webKey != "" && r.Header.Get("Authorization") == "" {
r.Header.Set("Authorization", "Bearer "+webKey)
}
+8 -3
View File
@@ -2,12 +2,15 @@ package loki
// sys_brand_icon.go — l'icône de la marque Loki, rendue à la volée.
//
// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis NOIR + « j »
// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis SAUGE + « j »
// blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). Une seule source pour
// tous les usages — zone de notification Windows, barre de menus macOS, icône du
// .exe — pour qu'ils ne puissent plus diverger comme quand le favicon est passé
// au noir en laissant les icônes système en bleu.
//
// La sauge est --accent de l'interface (#5E7F5A) : l'icône de l'onglet et celle
// du bureau portent la couleur de l'app, pas un carré noir anonyme.
//
// Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est
// produite par `go generate ./cmd/loki` (voir tools/gen-icon).
@@ -22,6 +25,8 @@ import (
const trayIconSize = 32
var (
// brandSage = --accent du thème clair. Le blanc du « j » s'y lit à 4,9:1.
brandSage = color.RGBA{0x5e, 0x7f, 0x5a, 0xff}
brandBlack = color.RGBA{0x00, 0x00, 0x00, 0xff}
brandWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
brandClear = color.RGBA{0, 0, 0, 0}
@@ -80,9 +85,9 @@ func encodePNG(img *image.RGBA) []byte {
return buf.Bytes()
}
// BrandIconPNG rend l'icône de marque (noir + « j » blanc) en PNG de n pixels.
// BrandIconPNG rend l'icône de marque (sauge + « j » blanc) en PNG de n pixels.
// Exporté pour le générateur d'icône du .exe (tools/gen-icon).
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) }
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandSage, brandWhite)) }
// brandTemplatePNG rend la variante « template » attendue par macOS : seule la
// couche alpha compte, le système colore la forme selon le thème de la barre de
+1
View File
@@ -75,6 +75,7 @@ var configTemplate = []struct{ key, help string }{
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high"},
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1311 -289
View File
File diff suppressed because it is too large. Load diff
+159 -79
View File
@@ -12,8 +12,8 @@
<!-- Favicon (même marque « loki » que la landing/portail) + icône PWA (écran d'accueil
iOS). SVG inline pour le favicon, PNG 180×180 en data-URI pour apple-touch-icon
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%23000'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABBUlEQVR42u3SAQkAAAwCQfuX3lI4GNxF0E8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIArU2NbcYhDHOIQhzgQB+JAHIgDcSAOxIE4xCEOcYhDHIgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQByIQxziEIc4xIE4EAfiQByIA3EgDsQhDnGIQxziEIc4EAfiQByIAxciDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAciEMc4hCHOMQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADfLBHMhi4TnG6EAAAAAElFTkSuQmCC">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAYAAAA9zQYyAAADD0lEQVR4nOzdW27aWgBGYag6ylZqh1I6lFZKppmjEylRLlyMsbG9+n3vsP2w8mfDC193d/Dj17ene5zDuv39/bif+4xZDhAwQ8wR+GRvKGJuMVXcN7+JkJnSrWGPfrGQmdPYsL+MeZGYmdvYxq76KxAyS7hmrQcvtJhZyjXtDQpazCxtaIMXgxYzazGkxbNBi5m1udTkyaDFzFqda3PU13awVkeDts6s3alGPwUtZrbiWKuuHKS8C9o6szUfm7XQpLwGbZ3ZqrftWmhSBE3Kc9CuG2zdS8MWmhRBkyJoUvbuz5RYaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSbnLj9ez2/05PCxy7s/D90XOXYqFJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAialK9LP8C9/Tk8LHLuz8P3Rc7911hoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZl/+PXt6elHwKmYqFJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgibly9/fj/ulHwKm8H/LFpoUQZMiaFKeg3aPZuteGrbQpAialNegXTvYqrftWmhS3gVtpdmaj81aaFI+BW2l2YpjrR5daFGzdqcadeUg5WTQVpq1Otfm2YUWNWtzqcmLVw5RsxZDWhx0hxY1Sxva4OAPhaJmKde0NypSPwXHPYwZ0VFf21lr5ja2sZvDtNZM6daxnGxphc0tpvqvP8vVQdwMMcfV9S53YYGzu9Nnr/8CAAD//xZRk2OJYiOqAAAAAElFTkSuQmCC">
<!-- Marque le mode PWA AVANT le rendu : la hauteur de coque et le défilement du
document en dépendent (voir styles.css). ⚠️ On NE PEUT PAS se fier au média
`display-mode:standalone` : WebKit l'adosse au `display` du MANIFESTE, or cette
@@ -22,30 +22,43 @@
garde `display-mode` en second pour les PWA Android/desktop à manifeste. -->
<script>try{var pwa=(window.navigator.standalone===true)||(window.matchMedia&&window.matchMedia('(display-mode:standalone)').matches)||(window.matchMedia&&window.matchMedia('(display-mode:fullscreen)').matches);document.documentElement.setAttribute('data-pwa',pwa?'1':'0');}catch(e){document.documentElement.setAttribute('data-pwa','0');}</script>
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
<script>try{var t=localStorage.getItem('loki-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('loki-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<script>try{var t=localStorage.getItem('loki-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('loki-'+k)==='1'?'1':'0')});
/* Barre latérale escamotée et moniteur replié : posés AVANT le rendu, sinon la
barre s'affiche puis se referme sous les yeux à chaque chargement. Ouverts par
défaut — une valeur absente vaut « visible ». */
document.documentElement.setAttribute('data-side',localStorage.getItem('loki-side')==='0'?'0':'1');
}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style>
@@CSS@@
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
fermé sur téléphone. Sans ce rappel, un rechargement de page donnait
l'impression que l'installation avait disparu. -->
<button id="lc-chip" onclick="lcChipOpen()" hidden></button>
<div id="backdrop" onclick="toggleSide()"></div>
<div class="side" id="side">
<!-- sidecard : regroupe la marque et la mention du fork en UNE carte (direction
Terre). Sans ce conteneur, les deux blocs flottaient séparément. -->
<div class="sidecard">
<!-- Trois zones : en-tête figé, contenu défilant, moniteur machine en pied.
C'est ce découpage qui garde les jauges visibles quel que soit le nombre
de discussions — elles les poussaient auparavant hors de l'écran. -->
<div class="sidehead">
<div class="sidebrand">
<h1 id="brand" aria-label="loki" title="Loki — fork d'AJEAN (github.com/nathaninline/ajean, MIT)"><span id="brand-a"><svg viewBox="0 0 4 5" shape-rendering="crispEdges" fill="currentColor"><rect x="0" y="0" width="1" height="5"/><rect x="1" y="4" width="3" height="1"/></svg></span><span id="brand-word"><svg viewBox="0 0 11 5" shape-rendering="crispEdges" fill="currentColor"><rect x="0" y="0" width="3" height="1"/><rect x="0" y="1" width="1" height="3"/><rect x="2" y="1" width="1" height="3"/><rect x="0" y="4" width="3" height="1"/><rect x="4" y="0" width="1" height="5"/><rect x="6" y="0" width="1" height="2"/><rect x="5" y="2" width="1" height="1"/><rect x="6" y="3" width="1" height="2"/><rect x="8" y="0" width="3" height="1"/><rect x="9" y="1" width="1" height="3"/><rect x="8" y="4" width="3" height="1"/></svg></span><span id="ver"></span></h1>
<!-- Attribution du fork (licence MIT de l'amont) : sous le nom de l'app,
là où elle a toujours vécu. -->
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
</div><!-- /.sidebrand -->
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
</div>
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
</div><!-- /sidecard -->
<div id="side-scroll">
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
actif est partagé par tous les appareils (comme la conversation unique
d'origine) : basculer ici bascule partout. -->
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion">+</button></summary>
d'origine) : basculer ici bascule partout.
La recherche filtre CÔTÉ CLIENT : la liste est déjà entièrement chargée
(métadonnées seules, sans les messages), un aller-retour serveur par
frappe n'apporterait rien. -->
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion" aria-label="nouvelle discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
<input id="conv-search" type="search" placeholder="rechercher une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
<div id="conv-list" class="slist"></div>
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
@@ -58,10 +71,15 @@
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
empilait 11 arrondis et autant de gouttières : la barre latérale ne
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
<!-- Tout ce qui n'est pas la conversation tient sous CE repli : la barre
latérale par défaut, c'est les discussions et la machine. Les sections
ouvertes par programme (journal du moteur, installation llama.cpp)
passent par openDetails(), qui déplie aussi les parents. -->
<details id="side-settings"><summary><span class="stitle">Réglages</span></summary>
<div class="sidegroup">
<details id="svc-log-box" ontoggle="if(this.open){loadSvcLog();showPaths()}"><summary><span class="stitle">Journal du moteur</span></summary>
<div class="row" style="justify-content:flex-end;align-items:center">
<button onclick="loadSvcLog()" title="rafraîchir">↻</button>
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
</div>
<pre id="svc-log" class="muted" style="font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:6px 0 0"></pre>
<!-- Emplacements : au même endroit que le journal, c'est le panneau où l'on
@@ -71,18 +89,10 @@
</div>
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
</details>
<!-- GPU et RAM décrivent la même chose (les ressources de la machine) :
une seule section, deux blocs de statistique dans la même carte. -->
<details open><summary><span class="stitle">Machine</span></summary>
<div class="slist">
<div id="vram">…</div>
<div id="ram-details"><div id="ram"></div></div>
</div>
</details>
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
<div id="cfg" class="slist">…</div>
</details>
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter">+</button></summary>
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
<div class="slist"><div id="presets" class="rowlist"></div></div>
</details>
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
@@ -158,7 +168,10 @@
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
<div id="mcp-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
<div class="mcp-actions">
<button class="addbtn" onclick="openMcpCatalog()">catalogue</button>
<button class="addbtn" onclick="openMcp('')">+ ajouter un serveur</button>
</div>
</div>
</div>
</div>
@@ -179,13 +192,24 @@
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
<div class="slist">
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
<!-- Moteur fourni par l'image Docker : aucun des trois modes d'installation
ne s'applique (rien à compiler, et l'image n'embarque ni cmake ni
compilateur). En revanche llama.cpp publie plusieurs versions par jour,
et son moteur déjà compilé est téléchargeable seul (~170 Mo) : cette
carte le met à jour sans reconstruire l'image. -->
<div id="lc-provided" class="rowbody" style="display:none">
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
<div style="font-size:12px;line-height:1.5">
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
<div id="eng-latest" style="margin-top:6px"></div>
<div class="row" style="margin:8px 0 0">
<button onclick="engCheck()">vérifier la version</button>
<button onclick="engUpdate()">mettre à jour le moteur</button>
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
</div>
<div class="muted" style="margin-top:6px">Télécharge llama-server et ses bibliothèques depuis l'image officielle
<code id="eng-repo"></code> (~170&nbsp;Mo), sans reconstruire l'image de Loki. Le nouveau moteur est essayé
avant d'être adopté ; celui de l'image reste intact et récupérable en un clic.</div>
</div>
</div>
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
@@ -269,40 +293,34 @@
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-reasoning" onchange="toggleView('hide-reasoning')"><span class="slider"></span></span> masquer le raisonnement</label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-tools" onchange="toggleView('hide-tools')"><span class="slider"></span></span> masquer les appels d'outils</label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-side" onchange="toggleView('hide-side')"><span class="slider"></span></span> barre latérale escamotable<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">La barre latérale devient un tiroir <b>même sur ordinateur</b> : elle reste fermée et s'ouvre avec le bouton ☰. Toute la largeur pour la conversation.</span></span></label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
</div>
</details>
<details><summary><span class="stitle">Accès OpenAI</span></summary>
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer &lt;clé&gt;</code>.</span></span></div>
<!-- UNE carte pour tout l'endpoint : adresse locale, bascule « public » et
adresse publique sont un seul sujet — trois cartes accolées donnaient
trois îlots pour un même réglage. -->
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer &lt;clé&gt;</code>.</span></span></div>
<div class="slist">
<div class="rowbody">
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>loki</code></div>
<div class="muted" style="margin:0 0 6px">Adresse de l'endpoint — modèle <code>loki</code></div>
<div class="row" style="margin:0">
<input id="oai-url" readonly class="io-input">
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
</div>
</div>
<!-- L'adresse ci-dessus ne sert à RIEN si le moteur n'écoute que sur la
boucle locale (le défaut sous Windows) : le chat du navigateur marche,
mais aucun logiciel du réseau ne peut s'y brancher. Cet interrupteur
règle HOST et pose la règle de pare-feu, voir sys_network.go. -->
<label class="switchrow"><span class="switch"><input type="checkbox" id="lan-toggle" onchange="toggleLAN()"><span class="slider"></span></span> joignable depuis le réseau local<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Éteint, le moteur n'écoute que sur <b>cette machine</b> : l'adresse ci-dessus ne répond qu'ici. Allumez-le pour brancher un logiciel depuis un autre ordinateur du réseau (sous Windows, la règle de pare-feu est posée en même temps). <b>Redémarre le moteur.</b></span></span></label>
<div id="lan-warn" class="rowbody" style="display:none"></div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend votre IA joignable depuis <b>n'importe où</b> via <code>https://&lt;machine&gt;.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite l'accès distant actif.</span></span></label>
<div id="oai-public-wrap" class="rowbody" style="display:none">
<div class="muted" style="margin:0 0 6px">Adresse publique — chiffrée de bout en bout, accessible partout</div>
<!-- Adresse publique : ce que Loki ne peut pas deviner. Vide, l'adresse
ci-dessus est celle par laquelle CE navigateur a ouvert l'interface —
déjà juste dans la plupart des cas. Renseignée, elle prend le pas :
c'est le cas du reverse proxy, où Loki ne voit qu'un appel interne. -->
<div class="rowbody">
<div class="muted" style="margin:0 0 6px">Adresse publique <span style="opacity:.7">— facultative</span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le nom de domaine ou l'IP par lesquels on atteint cette interface, par exemple <code>ia.exemple.fr</code> ou <code>192.168.1.20:8090</code>. <b>Laissez vide</b> et l'adresse affichée suit celle de votre navigateur. Renseignez-la si vous passez par un <b>reverse proxy</b> ou un nom de domaine : Loki ne voit alors qu'un appel interne et ne peut pas la deviner. Sans <code>https://</code>, il est ajouté.</span></span></div>
<div class="row" style="margin:0">
<input id="oai-public-url" readonly class="io-input">
<button class="iconbtn" onclick="copyText(document.getElementById('oai-public-url').value,'adresse publique copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
<input id="public-addr" class="io-input" placeholder="ia.exemple.fr" onkeydown="if(event.key==='Enter')savePublicAddr()">
<button onclick="savePublicAddr()">enregistrer</button>
</div>
</div>
</div>
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé <b>redémarre</b> le service.</span></span></div>
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions — vérifiée par Loki <b>et</b> par le moteur. Indispensable dès que l'adresse ci-dessus est joignable par d'autres. Modifier la clé <b>redémarre</b> le moteur.</span></span></div>
<div class="slist"><div class="rowbody">
<div id="oai-open-warn" style="display:none;margin:0 0 8px;font-size:11.5px;line-height:1.5;color:var(--err)"></div>
<div class="row" style="margin:0">
<input id="oai-key" readonly class="io-input">
<button id="oai-key-eye" onclick="toggleKeyReveal()" title="afficher / masquer">afficher</button>
@@ -328,13 +346,36 @@
<!-- Ligne 2 : bench + clear chat (seule action destructive, gardée en dernier). -->
<div class="row" style="margin-bottom:0">
<button id="btn-bench" onclick="runBenchUI()">bench</button>
<button onclick="resetChat()">⌫ clear chat</button>
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
</div>
</div></div>
</details>
</div><!-- /sidegroup -->
</details><!-- /#side-settings -->
</div><!-- /#side-scroll -->
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
jauges fines — visible en permanence sans jamais disputer la place au
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
bloc entier se replie d'un clic. -->
<div id="side-perf">
<div class="perf-head"><span>Performance</span></div>
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
</div>
</div>
<div class="main" style="position:relative">
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
<header id="topbar">
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
</button>
<div id="topbar-title">Nouvelle discussion</div>
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</header>
<div id="chat"></div>
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
@@ -349,7 +390,7 @@
pas de civilité ni d'accord qui obligerait à deviner. -->
<div id="ce-title">On commence par quoi ?</div>
</div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
<div id="composer">
@@ -361,18 +402,6 @@
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
<span id="ctx-text" class="muted">0 / 0</span>
<button id="ctx-compact" onclick="compactContext()">compacter</button>
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
gabarit que le trombone — ce sont les deux faces du même sujet,
ce qui entre et ce qui sort. -->
<button id="files-btn" onclick="toggleFiles()" title="Fichiers du dossier de travail" aria-label="Fichiers du dossier de travail" aria-expanded="false">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
</button>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
dont le rendu varie énormément d'un système à l'autre. -->
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.4 11.05 12.25 20.2a5.5 5.5 0 0 1-7.78-7.78l9.19-9.19a3.67 3.67 0 1 1 5.18 5.18l-9.2 9.2a1.83 1.83 0 1 1-2.59-2.6l8.49-8.48"/></svg>
</button>
</span>
</div>
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
@@ -380,27 +409,49 @@
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
<button id="send" onclick="send()">send</button>
<button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
</button>
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
ce qui sort. -->
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
</button>
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
reste porté par aria-label et l'infobulle — l'action garde son nom
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
</button>
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
</button>
</div>
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div>
</div>
<!-- Fichiers : ce que l'agent a écrit dans son dossier de travail. Panneau de
DROITE, symétrique des réglages à gauche, ouvert et fermé par le bouton
dossier du pied de carte. Il ne se remplit qu'à l'ouverture (loadFiles) —
un ReadDir à chaque chargement de page pour un panneau fermé serait payé
par tout le monde et utile à personne. -->
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
chargement de page pour un panneau fermé serait payé par tout le monde et
utile à personne. -->
<div id="files-backdrop" onclick="toggleFiles()"></div>
<aside id="files" aria-label="Fichiers du dossier de travail">
<aside id="files" aria-label="Fichiers de cette discussion">
<div class="files-head">
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
complet reste porté par l'aria-label de la section. -->
<span class="stitle">Fichiers</span>
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<!-- Fil d'Ariane : le dossier de travail contient des sous-dossiers (captures
par discussion), il faut pouvoir y descendre et en remonter. -->
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
captures), il faut pouvoir y descendre et en remonter. -->
<div id="files-crumb" class="files-crumb"></div>
<div id="files-list" class="files-list"></div>
<div id="files-foot" class="files-foot muted"></div>
@@ -410,7 +461,7 @@
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong>Benchmark</strong>
<button onclick="closeBenchModal()" style="margin:0">×</button>
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
@@ -427,7 +478,7 @@
<div class="modal-card" style="max-width:460px">
<div class="modal-head">
<strong>Exporter la conversation</strong>
<button class="modal-x" onclick="closeExportModal()" title="fermer">×</button>
<button class="modal-x" onclick="closeExportModal()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<!-- Conversation vide : pas d'options à régler, un seul message. Proposer
des réglages pour un fichier qui ne contiendrait rien n'a aucun sens. -->
@@ -500,10 +551,10 @@
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
<strong>Backends personnalisés</strong>
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button>
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div class="modal-body mform">
<div class="muted" style="font-size:12px;line-height:1.5">Compile un fork de llama.cpp depuis un dépôt Git — pour les modèles qui exigent un moteur spécial (quant ternaire, etc.). Un backend personnalisé <b>ne remplace pas</b> le moteur : vous le choisissez ensuite <b>par modèle</b> (bouton ✎ → section Moteur → « backend détecté »).</div>
<div class="muted" style="font-size:12px;line-height:1.5">Compile un fork de llama.cpp depuis un dépôt Git — pour les modèles qui exigent un moteur spécial (quant ternaire, etc.). Un backend personnalisé <b>ne remplace pas</b> le moteur : vous le choisissez ensuite <b>par modèle</b> (bouton crayon → section Moteur → « backend détecté »).</div>
<div class="fld">
<label class="fld-l">Installés</label>
<div id="lc-custom-list" class="mcp-list"></div>
@@ -526,11 +577,28 @@
</div>
</div>
</div>
<div id="mcp-catalog-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcpCatalog()">
<div class="modal-card" style="max-width:640px">
<div class="modal-head">
<strong>Catalogue MCP</strong>
<button class="modal-x" onclick="closeMcpCatalog()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div class="modal-body">
<div class="muted" style="font-size:12px;margin-bottom:8px">
Serveurs connus, embarqués dans Loki — aucun appel réseau pour cette liste.
« Utiliser » remplit le formulaire d'ajout : la commande s'exécutera sur
cette machine, relis-la avant d'enregistrer.
</div>
<div id="mcp-catalog-tabs" class="mcp-cat-tabs"></div>
<div id="mcp-catalog-list" class="mcp-cat-list"></div>
</div>
</div>
</div>
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
<strong id="mcp-modal-title">Serveur MCP</strong>
<button class="modal-x" onclick="closeMcp()" aria-label="Fermer">×</button>
<button class="modal-x" onclick="closeMcp()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div class="modal-body mform">
<div class="fld">
@@ -589,7 +657,7 @@
<div class="modal-card">
<div class="modal-head">
<strong id="modal-title">Preset</strong>
<button class="modal-x" onclick="closeModal()" aria-label="Fermer">×</button>
<button class="modal-x" onclick="closeModal()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<!-- Chargement : la modale s'ouvre tout de suite, mais son contenu n'arrive
qu'après (jusqu'à 3 requêtes en accès distant). Ce voile se pose PAR-DESSUS
@@ -637,7 +705,7 @@
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">ou un projecteur mmproj — lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger" aria-label="Télécharger"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="M7.5 10.5 12 15l4.5-4.5"/><path d="M4.5 19.5h15"/></svg></button>
</div>
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
@@ -773,6 +841,18 @@
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row" id="s-reasoning-effort-row">
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">envoyée au gabarit du modèle — auto = ne rien imposer</span></span>
<span class="pe-row-c">
<select id="s-reasoning-effort" class="pe-val" onchange="cfgWriteKey('REASONING_EFFORT', this.value)">
<option value="">auto</option>
<option value="none">aucune</option>
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
</select>
</span>
</div>
<div class="pe-row">
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
+4 -7
View File
@@ -24,11 +24,14 @@ function initTheme(){
// Quatre interrupteurs indépendants. Chacun pose un attribut sur <html> ; le CSS
// fait le reste, sauf « replier les bulles » qui est un comportement (voir
// 08-chat-render).
//
// « barre latérale escamotable » a disparu d'ici : la refonte l'escamote pour de
// bon (bouton de l'en-tête, data-side — voir 18-shell.js), au lieu de la
// transformer en tiroir qui recouvre la conversation.
const VIEW_OPTS=[
{id:'hide-reasoning', label:'masquer le raisonnement'},
{id:'hide-tools', label:"masquer les appels d'outils"},
{id:'fold-tools', label:'garder les bulles repliées'},
{id:'hide-side', label:'barre latérale escamotable'},
{id:'hide-stats', label:'masquer la vitesse de génération'},
];
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
@@ -36,12 +39,6 @@ function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('loki-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
if(id==='hide-side' && !on){
document.getElementById('side').classList.remove('open');
document.getElementById('backdrop').classList.remove('open');
document.body.classList.remove('drawer-open');
}
}
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){
+51
View File
@@ -0,0 +1,51 @@
// ─── Jeu d'icônes ────────────────────────────────────────────────────────────
// Un seul trait pour toute l'interface : 24 unités de côté, 1,8 d'épaisseur,
// extrémités et jonctions arrondies — celui des deux icônes de la maquette
// (dépôt et envoi), étendu au reste de l'application.
//
// Elles étaient jusqu'ici 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 carrément absent sur
// un serveur sans police d'émojis. Aucune cohérence possible, et rien à régler
// depuis la feuille de style. En SVG, elles suivent currentColor et la taille
// qu'on leur donne.
//
// Ajouter une icône : une entrée ici, et rien d'autre — surtout pas un second
// dessin ailleurs, c'est ainsi que le trait finit par diverger.
const ICONS = {
menu: '<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>',
close: '<path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/>',
refresh: '<path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/>',
pencil: '<path d="M4 20h4L19 9a2.1 2.1 0 0 0-3-3L5 17z"/><path d="M14.5 7.5l3 3"/>',
trash: '<path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/>',
download: '<path d="M12 4v11"/><path d="M7.5 10.5 12 15l4.5-4.5"/><path d="M4.5 19.5h15"/>',
arrowDown: '<path d="M12 5v14"/><path d="M6 13l6 6 6-6"/>',
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
file: '<path d="M13.5 3.5H7A1.5 1.5 0 0 0 5.5 5v14A1.5 1.5 0 0 0 7 20.5h10a1.5 1.5 0 0 0 1.5-1.5V8.5z"/><path d="M13.5 3.5V8.5h5"/>',
image: '<rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.4"/><path d="M4.5 17.5 10 12l3.5 3.5L16 13l3.5 3.5"/>',
plus: '<path d="M12 5.5v13"/><path d="M5.5 12h13"/>',
chevron: '<path d="M9.5 5.5 16 12l-6.5 6.5"/>',
search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.4-4.4"/>',
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2"/><path d="M12 19.3v2.2"/><path d="M4.2 4.2l1.6 1.6"/><path d="M18.2 18.2l1.6 1.6"/><path d="M2.5 12h2.2"/><path d="M19.3 12h2.2"/><path d="M4.2 19.8l1.6-1.6"/><path d="M18.2 5.8l1.6-1.6"/>',
};
// iconHTML rend l'icône en chaîne, pour les endroits qui composent déjà du
// balisage. `aria-hidden` : le sens est porté par le title/aria-label du bouton,
// jamais par le dessin.
function iconHTML(name, size){
const d = ICONS[name];
if(!d) return '';
const n = size || 18;
return '<svg viewBox="0 0 24 24" width="' + n + '" height="' + n + '" fill="none" '
+ 'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '
+ 'stroke-linejoin="round" aria-hidden="true">' + d + '</svg>';
}
// icon rend l'icône en ÉLÉMENT, pour les listes construites en DOM (fichiers,
// discussions) où l'on ne veut pas d'innerHTML.
function icon(name, size){
const span = document.createElement('span');
span.className = 'icn';
span.innerHTML = iconHTML(name, size); // contenu constant, jamais de donnée
return span.firstElementChild || span;
}
+64 -19
View File
@@ -42,7 +42,9 @@ async function loadStatus(){
function toggleSvcLog(){
const box=document.getElementById('svc-log-box');
if(!box) return;
box.open = !box.open;
// openDetails déplie aussi « Réglages » : sans lui, la section s'ouvrait à
// l'intérieur d'un repli fermé — donc nulle part, du point de vue de l'écran.
if(box.open) box.open = false; else openDetails(box);
}
async function loadSvcLog(){
const el=document.getElementById('svc-log');
@@ -127,30 +129,64 @@ function updateCtxMeter(){
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
}
// ─── Moniteur machine (pied de barre latérale) ───────────────────────────────
// Une jauge par ressource : nom, pourcentage, barre, puis le détail chiffré en
// dessous. C'est la hiérarchie de la maquette — on lit la charge d'un coup
// d'œil, et le détail (Gio, température) reste là pour qui le cherche.
//
// Construit EN DOM et non en innerHTML : les noms de GPU viennent du pilote,
// donc d'ailleurs. Même règle que pour les noms de fichiers et les titres de
// discussions.
function perfRow(label, pct, title){
pct = Math.max(0, Math.min(100, Math.round(pct||0)));
const row=document.createElement('div'); row.className='perf-row';
if(title) row.title=title;
const l=document.createElement('div'); l.className='perf-l';
const b=document.createElement('b'); b.textContent=label;
const v=document.createElement('span'); v.className='perf-pct'; v.textContent=pct+'%';
l.append(b,v);
const track=document.createElement('div'); track.className='perf-track';
const fill=document.createElement('div');
// Sauge jusqu'à 75 %, ambre au-delà, rouille à 90 : la couleur ne sert à rien
// tant que la machine respire, elle doit sauter aux yeux quand elle sature.
fill.className='perf-fill'+(pct>=90?' max':pct>=75?' hot':'');
fill.style.width=pct+'%';
track.appendChild(fill);
row.append(l,track);
return row;
}
async function loadVram(){
const gpus=await jget('/api/vram');
// Bloc de statistique : intitulé + valeur sur une ligne, jauge, détail dessous.
// Même gabarit que la RAM (voir .stat dans le CSS) — le HTML libre d'avant
// collait aux bords de la carte.
document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{
const pct=Math.round(g.used*100/g.total);
return '<div class="stat"><div class="stat-h"><span class="stat-n">'+g.name+'</span>'+
'<span class="stat-v">'+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB</span></div>'+
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
'<div class="stat-s">GPU '+g.util+' % · '+g.temp+' °C</div></div>';
}).join('') || '<div class="stat"><span class="stat-s">(pas de GPU)</span></div>';
const box=document.getElementById('vram');
if(!box) return;
box.textContent='';
if(!gpus || !gpus.length){
const d=document.createElement('div'); d.className='perf-sub'; d.textContent='(pas de GPU détecté)';
box.appendChild(d);
return;
}
// Deux mesures par carte, deux jauges — une seule barre ne peut pas porter la
// charge du processeur ET le remplissage de la mémoire vidéo, et pour du LLM
// c'est la VRAM qui décide si un modèle tient. Sur une machine à UNE carte on
// retombe exactement sur les trois jauges de la maquette (GPU, VRAM, RAM).
const many = gpus.length > 1;
gpus.forEach((g,i)=>{
const vram=(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' Gio';
box.appendChild(perfRow('GPU '+i, g.util, g.name+' · '+vram+' · '+g.temp+' °C'));
box.appendChild(perfRow(many ? 'VRAM '+i : 'VRAM', g.used*100/g.total,
g.name+' · '+vram));
});
}
async function loadRam(){
const m=await jget('/api/ram');
const box=document.getElementById('ram-details');
if(!m || !m.total){ if(box) box.style.display='none'; return; }
if(box) box.style.display='';
const pct=Math.round(m.used*100/m.total);
document.getElementById('ram').innerHTML =
'<div class="stat"><div class="stat-h"><span class="stat-n">Mémoire vive</span>'+
'<span class="stat-v">'+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB</span></div>'+
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
'<div class="stat-s">'+pct+' % utilisée</div></div>';
if(!box) return;
if(!m || !m.total){ box.style.display='none'; return; }
box.style.display='';
const host=document.getElementById('ram');
host.textContent='';
host.appendChild(perfRow('Mémoire vive', m.used*100/m.total,
(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' Gio utilisés'));
}
async function loadCfg(){
// /api/llamacpp en parallèle : il indique si le BIN de la config correspond au
@@ -165,11 +201,20 @@ async function loadCfg(){
// En conteneur, le moteur vient de l'image : le dire, plutôt que de le
// ranger dans « personnalisé » qui laisse croire à un bricolage.
if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image';
// Moteur mis à jour depuis l'image officielle (Réglages → Moteur) : c'est la
// voie normale en conteneur, pas un « personnalisé ».
else if(lc && lc.engine && lc.engine.in_use){
const b = (lc.engine.tag||'').match(/b\d+$/);
v = b ? 'llama.cpp '+b[0]+' (mis à jour)' : 'llama.cpp mis à jour';
}
else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
else if(lc && lc.in_use) v='llama.cpp compilé';
else v='llama.cpp personnalisé : '+c.BIN;
rows.push(row('MOTEUR', v, c.BIN));
}
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
// source qui le connaisse quand aucun preset ne correspond à la configuration.
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
rows.push(row(k, v));
+36 -79
View File
@@ -44,6 +44,8 @@ async function loadPresets(){
const sp = document.getElementById('status-preset');
sp.textContent = act ? act.name : '';
sp.title = act ? 'preset actif' : '';
// Sélecteur de modèle de l'en-tête : même donnée, autre point d'entrée.
if(typeof renderModelSwitch === 'function') renderModelSwitch(p, act);
const cont=document.getElementById('presets');
cont.innerHTML='';
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
@@ -229,27 +231,39 @@ async function copyText(txt, msg){
function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); }
function renderApiKey(d){
OAI_KEY = d.key || '';
// URL de l'endpoint : llama-server tourne sur d.port (≠ port de l'UI web).
// On prend l'hôte annoncé par le serveur (IP LAN détectée côté Go) : dans le
// tunnel ajean.link, location.hostname serait le domaine du relais (faux) —
// l'accès OpenAI reste TOUJOURS l'adresse locale de la machine.
const host = d.host || location.hostname;
document.getElementById('oai-url').value = 'http://'+host+':'+d.port+'/v1';
// Endpoint PUBLIC (ajean.link) : affiché seulement si l'accès public est activé.
const tg = document.getElementById('oai-public-toggle');
if(tg) tg.checked = !!d.oai_public;
const pubWrap = document.getElementById('oai-public-wrap');
if(d.oai_public && d.machine){
document.getElementById('oai-public-url').value = 'https://'+d.machine+'.oai.ajean.link/v1';
pubWrap.style.display = '';
} else {
pubWrap.style.display = 'none';
// L'adresse vient du SERVEUR (web_public_url.go) : adresse publique
// enregistrée, sinon l'origine par laquelle cette page a été ouverte. Elle
// était auparavant recomposée ici — 'http://'+host+':'+port+'/v1' — à partir
// du port du MOTEUR et d'une IP détectée côté Go, ce qui donnait en conteneur
// une adresse que personne ne pouvait joindre (bridge Docker, port non publié).
// location.origin ne sert plus que de filet si le serveur ne dit rien.
document.getElementById('oai-url').value = d.url || (location.origin + '/v1');
// Champ d'adresse publique : on ne l'écrase pas pendant la frappe.
const pa = document.getElementById('public-addr');
if(pa && document.activeElement !== pa) pa.value = d.public_url || '';
// Sans clé, l'endpoint est ouvert — et il l'est maintenant partout où
// l'interface l'est. Ça ne se dit pas à voix basse.
const warn = document.getElementById('oai-open-warn');
if(warn){
warn.style.display = d.set ? 'none' : '';
warn.textContent = d.set ? '' :
'⚠ Aucune clé : toute personne capable de joindre cette adresse peut utiliser votre modèle.';
}
const inp=document.getElementById('oai-key');
if(!d.set){ inp.value='(aucune clé — serveur ouvert)'; inp.style.opacity=.6; }
if(!d.set){ inp.value='(aucune clé — endpoint ouvert)'; inp.style.opacity=.6; }
else { inp.style.opacity=1; inp.value = OAI_REVEAL ? OAI_KEY : d.masked; }
document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none';
}
// Enregistre l'adresse publique. Le serveur normalise et refuse en clair : on
// relaie sa raison telle quelle, elle est écrite pour être lue.
async function savePublicAddr(){
const inp = document.getElementById('public-addr');
if(!inp) return;
const r = await jpost('/api/oai/public', {url: inp.value});
if(!r || !r.ok){ toast('adresse refusée : ' + ((r && r.error) || '')); return; }
toast(r.url ? 'adresse enregistrée' : 'adresse effacée — celle du navigateur sera utilisée');
loadApiKey();
}
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
// --- Export de la conversation ---------------------------------------------
// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de
@@ -381,50 +395,11 @@ async function downloadExport(url){
toast('exporté : ' + name);
}catch(e){ toast('erreur : ' + e.message); }
}
// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------
// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le
// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le
// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part.
function renderNetwork(st){
if(!st) return;
const cb = document.getElementById('lan-toggle');
if(cb) cb.checked = !!st.exposed;
const warn = document.getElementById('lan-warn');
if(!warn) return;
if(!st.exposed){
warn.style.display = '';
warn.innerHTML = '<div class="muted" style="margin:0">Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.</div>';
return;
}
// Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute
// partout » et pourtant rien ne passe). On donne la commande à coller.
if(st.hint){
warn.style.display = '';
// --err et non --warn : la palette est volontairement monochrome et --warn y
// est un gris, illisible comme alerte. Ici il y a une vraie action à faire.
warn.innerHTML = '<div style="margin:0;color:var(--err)">⚠ '
+ escHtml(st.hint).replace(/\n/g,'<br>') + '</div>';
return;
}
warn.style.display = 'none';
}
async function loadNetwork(){
try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){}
}
async function toggleLAN(){
const cb = document.getElementById('lan-toggle');
const on = cb.checked;
const r = await jpost('/api/network', {exposed:on});
if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; }
renderNetwork(r.status);
// llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur
// affiche un état que le moteur en cours ne respecte pas encore.
if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).',
{title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){
await act('restart'); // même chemin que les boutons du panneau Moteur
}
loadApiKey();
}
// L'écoute réseau du moteur (HOST + pare-feu) n'a plus d'interrupteur : depuis
// que Loki sert /v1 sur son propre port, ouvrir le moteur sur le réseau n'a plus
// d'objet — et deux interrupteurs pour « rendre l'IA joignable » était
// exactement la confusion qu'on voulait lever. La route /api/network et la
// commande `loki network` restent, pour qui veut exposer le moteur en direct.
function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; }
async function apiKeyAction(action){
if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return;
@@ -433,24 +408,6 @@ async function apiKeyAction(action){
toast('application…');
renderApiKey(await jpost('/api/apikey', {action}));
}
async function toggleOAIPublic(){
const cb = document.getElementById('oai-public-toggle');
const on = cb.checked;
// L'accès OpenAI public passe par ajean.link (<machine>.oai.ajean.link) : il exige
// que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique.
if(on){
let linked = false;
try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){}
if(!linked){
cb.checked = false;
await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'});
return;
}
}
await jpost('/api/oai/public', {enabled:on});
toast(on ? 'accès public activé' : 'accès public coupé');
loadApiKey();
}
async function apiKeySet(){
const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'});
if(!k || !k.trim()) return;
@@ -494,7 +451,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+14
View File
@@ -746,6 +746,20 @@ function populateSettings(){
rzSub.textContent = rz ? 'réflexion étape par étape'
: 'réflexion étape par étape — non précisé : le modèle décide';
}
// Intensité : passée telle quelle au gabarit jinja du modèle via
// `reasoning_effort`. Seuls les gabarits qui la lisent (gpt-oss et apparentés)
// changent de comportement ; ailleurs elle est ignorée sans erreur — le
// sous-titre le dit pour ne pas laisser croire à un effet garanti.
const eff = document.getElementById('s-reasoning-effort');
if(eff){
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
const effSub = document.getElementById('s-reasoning-effort-sub');
if(effSub){
effSub.textContent = eff.value
? 'envoyée au gabarit du modèle — sans effet si son gabarit l\'ignore'
: 'envoyée au gabarit du modèle — auto = ne rien imposer';
}
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));
+11 -2
View File
@@ -327,9 +327,18 @@ function addCopyButtons(root){
pre.appendChild(btn);
});
}
// Nouvelle conversation POUR TOUS LES APPAREILS : le serveur vide le fil et
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); toast('nouvelle conversation'); }
//
// Confirmé, car le geste emporte aussi les FICHIERS de la discussion (dépôts,
// captures, ce que l'agent y a écrit) : ils n'appartiennent qu'à elle, et plus
// un message ne les mentionnerait.
async function resetChat(){
if(!await askConfirm('Le fil de cette discussion et ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront effacés.\n\nC\'est définitif.',
{title:'Vider la discussion', okText:'Vider', danger:true})) return;
jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{});
toast('discussion vidée');
}
// Compaction : on demande à l'IA un résumé de la conversation destiné à la
// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
+26 -8
View File
@@ -22,7 +22,10 @@ function addPending(text){
clearPending();
PENDING=addMsg('user', text);
PENDING.classList.add('pending');
const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…';
// L'étiquette garde l'avatar et le prénom (paintLabel, posé par addMsg) : une
// bulle ne doit JAMAIS s'appeler autrement d'un tour à l'autre. L'envoi en
// cours se lit à la bulle grisée (.pending) et à l'infobulle.
const l=PENDING.querySelector('.label'); if(l) l.title='envoi en cours…';
jumpBottom();
return PENDING;
}
@@ -33,7 +36,11 @@ function confirmPending(text){
const b=PENDING.querySelector('.body');
if(!b || b.textContent!==text) return false;
PENDING.classList.remove('pending');
const l=PENDING.querySelector('.label'); if(l) l.textContent='user';
// ⚠️ NE PAS réécrire l'étiquette ici : elle porte l'avatar et le prénom
// (17-identity.js). Elle y était remise à « user », si bien que le message
// qu'on venait d'envoyer s'affichait « USER » alors que ceux rejoués au
// chargement portaient le prénom — deux bulles identiques, deux libellés.
const l=PENDING.querySelector('.label'); if(l) l.removeAttribute('title');
PENDING=null;
return true;
}
@@ -114,8 +121,14 @@ function renderStats(el, s){
function labelTokens(el, role, n, firstTs, lastTs){
if(!el) return;
const secs=(lastTs-firstTs)/1000;
if(secs>0.05 && n>1){ setLabel(el, role+' · '+n+' tok · '+(n/secs).toFixed(1)+' tok/s'); }
else { setLabel(el, role+' · '+n+' tok'); }
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
// Bulle technique : son étiquette EST le bouton de repli, les mesures y vont.
// Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures
// vont dans la ligne dédiée — comme le font déjà les stats de fin de tour
// (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct
// par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement.
if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m);
else setStats(el, m);
}
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
@@ -123,8 +136,12 @@ function labelTokens(el, role, n, firstTs, lastTs){
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
function syncSendBtn(){
const sb=document.getElementById('send');
sb.style.display=busy?'none':'inline-block';
document.getElementById('stop').style.display=busy?'inline-block':'none';
// ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux
// boutons étaient montrés/cachés avec `style.display='inline-block'`, qui
// l'emportait sur le `display:flex` de la feuille : le bouton gardait sa
// boîte de 34 px mais son icône de 18 px se collait à gauche, décalée de 8 px.
// C'est invisible tant que l'icône est un glyphe de texte, flagrant en SVG.
document.documentElement.setAttribute('data-busy', busy ? '1' : '0');
// Tant que le moteur n'a pas fini de charger le modèle, envoyer ne mène à rien :
// on bloque le bouton et l'Entrée, et on le DIT sous le champ. `STATUS_SEEN`
// évite de verrouiller le chat quand /api/status n'a pas encore répondu (ou ne
@@ -154,8 +171,9 @@ function handleDelta(d){
return; }
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil.
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; }
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
+88 -8
View File
@@ -28,13 +28,13 @@ async function loadLlamacpp(){
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
if(s.job && s.job.exists && s.job.running && !lcPoll){
document.getElementById('lc-details').open = true;
openDetails('lc-details');
lcStartPolling();
}
// Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
// typiquement quand le service a redémarré) : on l'affiche sans polling.
else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
document.getElementById('lc-details').open = true;
openDetails('lc-details');
document.getElementById('lc-job').style.display = '';
lcLogNext = 0;
document.getElementById('lc-log').textContent = '';
@@ -56,7 +56,8 @@ async function loadLlamacpp(){
let lcSeenEnd = false, lcEndShown = false;
function lcChipLabel(action){
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
prebuilt:'Téléchargement du moteur', custom:'Installation du backend'}[action] || 'Installation du moteur';
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
custom:'Installation du backend'}[action] || 'Installation du moteur';
}
function lcChipSync(j){
const chip = document.getElementById('lc-chip');
@@ -73,7 +74,7 @@ function lcChipOpen(){
const side = document.getElementById('side');
if(!side.classList.contains('open')) toggleSide();
const det = document.getElementById('lc-details');
det.open = true;
openDetails(det); // déplie aussi « Réglages », qui contient la section
det.scrollIntoView({block:'center'});
if(!document.getElementById('lc-chip').classList.contains('failed')) return;
lcSeenEnd = true;
@@ -97,7 +98,7 @@ function lcRenderReco(reco){
if(desc && reco && reco.why) desc.textContent = reco.why;
}
// Bascule entre « moteur de l'image » (une ligne d'état) et les trois modes
// Bascule entre la carte « moteur de l'image » et les trois modes
// d'installation gérés par Loki.
function lcRenderProvided(s){
const box = document.getElementById('lc-provided');
@@ -108,8 +109,87 @@ function lcRenderProvided(s){
if(!s.provided) return;
const bin = document.getElementById('lc-provided-bin');
if(bin) bin.textContent = s.config_bin || '';
const job = document.getElementById('lc-job');
if(job) job.style.display = 'none';
loadEngine();
// Un job de mise à jour du moteur peut être en cours : le bloc de progression
// était masqué ici tant que la carte ne servait qu'à dire « rien à faire ».
if(s.job && s.job.exists && s.job.running && !lcPoll){
openDetails('lc-details');
lcStartPolling();
}
lcChipSync(s.job);
}
// --- Moteur de l'image : le mettre à jour sans reconstruire l'image ---------
// llama.cpp publie plusieurs versions par jour ; son moteur déjà compilé vit
// dans l'image officielle, dont on n'extrait que /app (~170 Mo). Voir
// web_engine.go pour le détail, et pourquoi ce n'est pas une release GitHub :
// llama.cpp n'en publie aucune pour CUDA/Linux.
let engState = null;
async function loadEngine(){
let s;
try{ s = await jget('/api/engine'); }catch(_){ return; }
engState = s;
const build = document.getElementById('eng-build');
if(build) build.textContent = s.build ? ('b'+s.build+(s.commit ? ' · '+s.commit : '')) : 'version inconnue';
const src = document.getElementById('eng-source');
if(src) src.textContent = {image:'fourni par l\'image', downloaded:'mis à jour par Loki',
prebuilt:'précompilé', custom:'personnalisé'}[s.source] || '';
const repo = document.getElementById('eng-repo');
if(repo) repo.textContent = s.repo + ':' + (s.variant || 'server');
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
const revert = document.getElementById('eng-revert');
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
engSay('');
}
// engSay écrit sous la ligne d'état : c'est le résultat d'une vérification, qui
// doit rester lisible après le toast (on ne se souvient pas d'un toast).
function engSay(html, cls){
const el = document.getElementById('eng-latest');
if(!el) return;
el.innerHTML = html;
el.className = cls || 'muted';
el.style.display = html ? '' : 'none';
}
async function engCheck(){
toast('vérification…');
let r;
try{ r = await jpost('/api/engine/check', {}); }catch(_){ toast('erreur réseau'); return; }
if(!r.ok){ engSay('✗ '+String(r.error||'').replace(/[<>&]/g,'')); toast('erreur'); return; }
if(r.update){
engSay('Version disponible : <b>b'+r.latest+'</b>'+(r.current ? ' (installée : b'+r.current+')' : '')
+ ' — « mettre à jour le moteur » pour l\'installer.');
toast('mise à jour disponible : b'+r.latest);
} else {
engSay('Moteur à jour ✓ (b'+r.latest+')');
toast('moteur à jour ✓');
}
}
async function engUpdate(){
if(!await askConfirm('Télécharger la dernière version de llama.cpp (~170 Mo) et l\'utiliser à la place du moteur de l\'image.\n\n'
+ 'Le moteur est essayé avant d\'être adopté ; celui de l\'image reste intact. Le moteur redémarre à la fin — la génération en cours sera coupée.',
{title:'Mettre à jour le moteur', okText:'Mettre à jour'})) return;
let r;
try{ r = await jpost('/api/engine/update', {}); }catch(_){ toast('erreur réseau'); return; }
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
openDetails('lc-details');
lcStartPolling();
}
// Bascule vers une version déjà installée — « image » = celle d'origine.
async function engUse(tag){
const image = tag === 'image';
if(!await askConfirm(image ? 'Repasser sur le moteur livré avec l\'image Docker. Le moteur redémarre.'
: 'Utiliser la version « '+tag+' ». Le moteur redémarre.',
{title:'Changer de moteur', okText:'Basculer'})) return;
let r;
try{ r = await jpost('/api/engine/use', {tag}); }catch(_){ toast('erreur réseau'); return; }
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
toast('moteur basculé — redémarrage en cours');
loadAll();
}
function lcRenderMode(mode, installed){
@@ -235,7 +315,7 @@ async function lcInstallCustom(){
const r = await jpost('/api/llamacpp/install-custom', {repo:url, name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
closeCustomBackends();
document.getElementById('lc-details').open = true;
openDetails('lc-details');
lcStartPolling();
}
+114 -1
View File
@@ -63,7 +63,8 @@ function renderMCP(r){
sw.appendChild(cb); sw.appendChild(sl);
const edit = document.createElement('button');
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
edit.className = 'mcp-edit'; edit.title = 'éditer';
edit.setAttribute('aria-label', 'éditer ce serveur'); edit.appendChild(icon('pencil', 15));
edit.onclick = (e)=>{ e.stopPropagation(); openMcp(s.name); };
row.appendChild(dot); row.appendChild(info); row.appendChild(sw); row.appendChild(edit);
@@ -77,6 +78,118 @@ async function toggleMcp(name, on){
toast(on ? 'serveur activé' : 'serveur désactivé');
}
// ─── Catalogue ───────────────────────────────────────────────────────────────
// Liste embarquée de serveurs connus, servie par /api/mcp/catalog. Choisir une
// entrée n'installe RIEN : ça ouvre la modale d'ajout préremplie, l'utilisateur
// relit la commande (qui s'exécutera sur cette machine) puis enregistre.
let mcpCatalog = [];
let mcpCatalogFilter = 'Tous';
async function openMcpCatalog(){
showModal('mcp-catalog-modal');
const list = document.getElementById('mcp-catalog-list');
if(!mcpCatalog.length){
list.innerHTML = '<div class="muted" style="font-size:12px">chargement…</div>';
const r = await jget('/api/mcp/catalog');
mcpCatalog = (r && r.catalog) || [];
}
renderMcpCatalog();
}
function closeMcpCatalog(){ hideModal('mcp-catalog-modal'); }
function renderMcpCatalog(){
const list = document.getElementById('mcp-catalog-list');
const tabs = document.getElementById('mcp-catalog-tabs');
list.textContent = '';
tabs.textContent = '';
if(!mcpCatalog.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(catalogue vide)</div>';
return;
}
const cats = ['Tous'].concat([...new Set(mcpCatalog.map(e=>e.category))].sort());
cats.forEach(c=>{
const b = document.createElement('button');
b.className = 'mcp-cat'+(c===mcpCatalogFilter?' on':'');
b.textContent = c;
b.onclick = ()=>{ mcpCatalogFilter = c; renderMcpCatalog(); };
tabs.appendChild(b);
});
mcpCatalog
.filter(e=>mcpCatalogFilter==='Tous' || e.category===mcpCatalogFilter)
.forEach(e=>{
const row = document.createElement('div');
row.className = 'mcp-cat-row';
const info = document.createElement('div'); info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name';
nm.textContent = e.name;
if(e.installed){
const tag = document.createElement('span'); tag.className = 'mcp-tag';
tag.textContent = 'installé'; tag.style.marginLeft = '6px';
nm.appendChild(tag);
}
const desc = document.createElement('div'); desc.className = 'mcp-cat-desc';
desc.textContent = e.description;
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const cmd = document.createElement('code'); cmd.className = 'mcp-cat-cmd';
cmd.textContent = [e.command].concat(e.args||[]).join(' ');
cmd.title = cmd.textContent;
meta.appendChild(cmd);
if(!e.runtimeOk){
const w = document.createElement('span'); w.className = 'mcp-err';
w.textContent = 'runtime '+(e.runtime||'?')+' absent';
w.title = e.runtime==='python'
? "uv/uvx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer."
: "npx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer.";
meta.appendChild(w);
}
if((e.secrets||[]).length){
const s = document.createElement('span'); s.className = 'mcp-tag';
s.textContent = 'clé requise'; s.title = e.secrets.join(', ');
meta.appendChild(s);
}
info.appendChild(nm); info.appendChild(desc); info.appendChild(meta);
const use = document.createElement('button');
use.className = 'addbtn mcp-cat-use';
use.textContent = e.installed ? 'revoir' : 'utiliser';
use.onclick = ()=>{ closeMcpCatalog(); openMcpFromCatalog(e); };
row.appendChild(info); row.appendChild(use);
list.appendChild(row);
});
}
// Préremplit la modale d'ajout à partir d'une entrée du catalogue. Le nom du
// serveur = l'id de l'entrée, pour que « déjà installé » se détecte ensuite.
function openMcpFromCatalog(e){
openMcp(mcpServers.some(s=>s.name===e.id) ? e.id : '');
if(!mcpServers.some(s=>s.name===e.id)){
document.getElementById('mcp-name').value = e.id;
document.querySelector('input[name="mcp-transport"][value="stdio"]').checked = true;
mcpTransportUI('stdio');
document.getElementById('mcp-command').value = e.command || '';
document.getElementById('mcp-args').value = (e.args||[]).join('\n');
document.getElementById('mcp-env').value =
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
document.getElementById('mcp-url').value = '';
document.getElementById('mcp-headers').value = '';
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
}
const st = document.getElementById('mcp-modal-status');
if((e.secrets||[]).length){
st.textContent = 'renseigne '+e.secrets.join(', ')+' avant d\'enregistrer';
st.style.color = 'var(--err)';
} else if(!e.runtimeOk){
st.textContent = '⚠ runtime '+(e.runtime||'?')+' absent : le serveur ne démarrera pas ici';
st.style.color = 'var(--err)';
}
}
// Ouvre la modale d'ajout (name='') ou d'édition (nom existant).
function openMcp(name){
mcpEditing = name || '';
+58 -11
View File
@@ -1,9 +1,15 @@
// ─── Fichiers du dossier de travail ──────────────────────────────────────────
// ─── Fichiers de la discussion ───────────────────────────────────────────────
// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici
// on ne pouvait récupérer un fichier que si le modèle avait pensé à en mettre le
// lien dans sa réponse — tout le reste restait invisible, et faire le ménage
// demandait un shell.
//
// Le panneau montre les fichiers de la discussion OUVERTE, et rien d'autre :
// chaque discussion a son dossier côté serveur (chat_convfiles.go), changer de
// discussion change donc la liste, et supprimer une discussion emporte ses
// fichiers. `FILES_SCOPE = 'legacy'` ouvre le détour « hors discussion » : les
// fichiers d'avant ce rangement, que la migration n'a pas su rattacher.
//
// Le panneau est construit EN DOM, jamais en innerHTML : les noms de fichiers
// viennent du modèle et du système de fichiers, donc d'ailleurs. Même règle que
// pour les titres de discussions (16-conversations.js).
@@ -11,7 +17,8 @@
// Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
// déjà gérer le blob et les cas particuliers du navigateur.
let FILES_DIR = ''; // dossier affiché, relatif au dossier de travail
let FILES_DIR = ''; // dossier affiché, relatif à la racine du panneau
let FILES_SCOPE = ''; // '' = discussion ouverte, 'legacy' = fichiers hors discussion
let FILES_OPEN = false;
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde
@@ -44,7 +51,7 @@ async function loadFiles(dir){
const foot = document.getElementById('files-foot');
if(!list) return;
let r;
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)); }
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)+'&scope='+encodeURIComponent(FILES_SCOPE)); }
catch(_){ filesMsg('lecture impossible'); return; }
if(!r.ok){
// Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre
@@ -58,13 +65,40 @@ async function loadFiles(dir){
if(!r.entries.length){
const d = document.createElement('div');
d.className = 'files-empty muted';
d.textContent = FILES_DIR ? '(dossier vide)' : "L'agent n'a encore rien écrit ici.";
d.textContent = FILES_DIR ? '(dossier vide)'
: (FILES_SCOPE === 'legacy' ? '(plus rien hors discussion)'
: "Rien dans cette discussion — les fichiers déposés et ceux que l'agent écrit apparaissent ici.");
list.appendChild(d);
}
for(const e of r.entries) list.appendChild(fileRow(e));
foot.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
renderFoot(foot, r);
}
// Pied : occupation disque de ce qui est affiché — la discussion ouverte, ou le
// hors-discussion — et le va-et-vient entre les deux. Le bouton n'apparaît que
// s'il reste vraiment quelque chose hors discussion (compté par le serveur), et
// disparaît de lui-même une fois le ménage fait.
function renderFoot(foot, r){
if(!foot) return;
foot.textContent = '';
const n = document.createElement('span');
n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
foot.appendChild(n);
foot.title = r.root || '';
if(FILES_SCOPE !== 'legacy' && !r.legacy) return;
const b = document.createElement('button');
b.className = 'files-scope';
if(FILES_SCOPE === 'legacy'){
b.textContent = '← cette discussion';
b.title = 'revenir aux fichiers de la discussion ouverte';
b.onclick = ()=>{ FILES_SCOPE = ''; loadFiles(''); };
} else {
b.textContent = 'hors discussion (' + r.legacy + ')';
b.title = "fichiers d'avant le rangement par discussion";
b.onclick = ()=>{ FILES_SCOPE = 'legacy'; loadFiles(''); };
}
foot.appendChild(b);
}
function filesMsg(txt){
@@ -76,7 +110,9 @@ function filesMsg(txt){
list.appendChild(d);
}
// Fil d'Ariane : « travail / captures / <id> », chaque segment cliquable.
// Fil d'Ariane : « discussion / captures », chaque segment cliquable. La racine
// est le dossier de la discussion ouverte — on n'en sort pas, sauf par le
// détour « hors discussion », qui a sa propre racine.
function renderCrumb(r){
const c = document.getElementById('files-crumb');
c.textContent = '';
@@ -89,7 +125,7 @@ function renderCrumb(r){
c.appendChild(b);
};
const parts = r.dir ? r.dir.split('/') : [];
seg('travail', '', parts.length === 0);
seg(FILES_SCOPE === 'legacy' ? 'hors discussion' : 'discussion', '', parts.length === 0);
let acc = '';
parts.forEach((p, i)=>{
acc = acc ? acc + '/' + p : p;
@@ -106,7 +142,7 @@ function fileRow(e){
const ic = document.createElement('span');
ic.className = 'files-ic';
ic.textContent = e.dir ? '▸' : (e.image ? '▣' : '·');
ic.appendChild(icon(e.dir ? 'folder' : (e.image ? 'image' : 'file'), 16));
row.appendChild(ic);
const mid = document.createElement('span');
@@ -135,12 +171,14 @@ function fileRow(e){
acts.className = 'files-acts';
if(!e.dir){
const dl = document.createElement('button');
dl.className = 'iconbtn'; dl.textContent = '↓'; dl.title = 'télécharger';
dl.className = 'iconbtn'; dl.appendChild(icon('download', 16));
dl.title = 'télécharger'; dl.setAttribute('aria-label', 'télécharger');
dl.onclick = ev=>{ ev.stopPropagation(); downloadWorkspaceFile(e.path, e.name, row); };
acts.appendChild(dl);
}
const rm = document.createElement('button');
rm.className = 'iconbtn'; rm.textContent = '🗑'; rm.title = 'supprimer';
rm.className = 'iconbtn'; rm.appendChild(icon('trash', 16));
rm.title = 'supprimer'; rm.setAttribute('aria-label', 'supprimer');
rm.onclick = ev=>{ ev.stopPropagation(); removeFile(e); };
acts.appendChild(rm);
row.appendChild(acts);
@@ -155,12 +193,21 @@ async function removeFile(e){
: '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
{title:'Supprimer ?', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/chat/file/delete', {path: e.path});
const r = await jpost('/api/chat/file/delete', {path: e.path, scope: FILES_SCOPE});
if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
loadFiles();
}
// Changement de discussion (bascule, « vider »), signalé par le flux SSE :
// le panneau montre les fichiers de la discussion OUVERTE, il doit donc être
// redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui
// de l'autre discussion, il n'existe pas forcément ici.
function filesOnConvChange(){
FILES_DIR = ''; FILES_SCOPE = '';
if(FILES_OPEN) loadFiles('');
}
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
document.addEventListener('DOMContentLoaded', initFiles);
+61 -17
View File
@@ -8,43 +8,83 @@
// Les titres viennent du premier message de l'utilisateur : ils sont construits
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
// balisage ni les gestionnaires d'événement.
//
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
// titre, dates, nombre d'échanges — jamais les messages.
let CONVS = [], CONV_ACTIVE = '';
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
CONV_ACTIVE = r.active || '';
renderConversations();
}
function renderConversations(){
const cont = document.getElementById('conv-list');
if(!cont) return;
cont.innerHTML = '';
const list = r.conversations || [];
if(!list.length){ cont.innerHTML = '<span class="muted">(aucune)</span>'; return; }
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
if(!list.length){
const e = document.createElement('div');
e.className = 'conv-empty';
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
cont.appendChild(e);
syncTopbarTitle();
return;
}
for(const c of list){
const row = document.createElement('div');
row.className = 'preset' + (c.id === r.active ? ' active' : '');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
row.onclick = () => convSwitch(c.id);
const info = document.createElement('div'); info.className = 'preset-info';
const nm = document.createElement('div'); nm.className = 'preset-name';
if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); }
const title = c.title || 'Nouvelle discussion';
nm.appendChild(document.createTextNode(title));
nm.title = title;
const meta = document.createElement('div'); meta.className = 'preset-meta';
const when = document.createElement('span'); when.className = 'muted';
when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
meta.appendChild(when);
info.appendChild(nm); info.appendChild(meta);
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
info.appendChild(nm);
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
// donc jamais et rien ne se chevauche.
const when = document.createElement('span');
when.className = 'conv-when';
when.textContent = convWhen(c.updated);
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
const acts = document.createElement('div'); acts.className = 'conv-acts';
const ren = document.createElement('button'); ren.className = 'iconbtn'; ren.textContent = '✎';
ren.title = 'renommer'; ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
const del = document.createElement('button'); del.className = 'iconbtn'; del.textContent = '🗑';
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
const ren = document.createElement('button'); ren.className = 'iconbtn';
ren.appendChild(icon('pencil', 15)); ren.title = 'renommer';
ren.setAttribute('aria-label', 'renommer la discussion');
ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
const del = document.createElement('button'); del.className = 'iconbtn';
del.appendChild(icon('trash', 15)); del.title = 'supprimer';
del.setAttribute('aria-label', 'supprimer la discussion');
del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
acts.appendChild(ren); acts.appendChild(del);
row.appendChild(info); row.appendChild(acts);
row.append(info, when, acts);
cont.appendChild(row);
}
syncTopbarTitle();
}
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
// quand la barre latérale est escamotée — c'est-à-dire quand on en a besoin.
function syncTopbarTitle(){
const el = document.getElementById('topbar-title');
if(!el) return;
const cur = CONVS.find(c => c.id === CONV_ACTIVE);
const t = (cur && cur.title) || 'Nouvelle discussion';
el.textContent = t;
el.title = t;
}
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
@@ -62,6 +102,8 @@ async function convNew(){
try{
await jpost('/api/conversations/new', {});
toast('nouvelle discussion');
const s = document.getElementById('conv-search');
if(s) s.value = ''; // sinon la discussion toute neuve est masquée par le filtre
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
@@ -85,7 +127,9 @@ async function convRename(id, current){
}
async function convDelete(id, title){
if(!await askConfirm('Supprimer « ' + title + ' » ? Cette discussion et son historique seront perdus.',
// Le geste emporte aussi les fichiers de la discussion : ils lui appartiennent
// (un dossier par discussion côté serveur), autant le dire avant.
if(!await askConfirm('Supprimer « ' + title + ' » ? Son historique ET ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront perdus.',
{title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
try{
const r = await jpost('/api/conversations/delete', {id});
+115
View File
@@ -0,0 +1,115 @@
// ─── Coque de l'interface ────────────────────────────────────────────────────
// Les commandes de la refonte : escamoter la barre latérale, replier le
// moniteur machine, basculer le thème depuis l'en-tête, changer de modèle sans
// ouvrir les réglages.
//
// Chaque état vit sur <html> (data-side, data-perf, data-theme) et dans
// localStorage, et il est REPOSÉ AVANT LE RENDU par le script du <head> : sans
// ça, la barre latérale s'afficherait puis se refermerait sous les yeux à chaque
// chargement de page.
// Escamotage de la barre latérale (grand écran). Sur téléphone elle est déjà un
// tiroir — c'est toggleSide() qui s'en charge, et le bouton ☰ qui l'ouvre.
function toggleSideCollapse(){
if(window.innerWidth <= 720){ toggleSide(); return; }
const on = document.documentElement.getAttribute('data-side') !== '0';
document.documentElement.setAttribute('data-side', on ? '0' : '1');
try{ localStorage.setItem('loki-side', on ? '0' : '1'); }catch(_){}
// La largeur du fil change sans qu'aucun `resize` ne soit émis : la gouttière
// de barre de défilement mesurée dans --sbw resterait celle d'avant, et la
// carte de saisie serait décalée du fil.
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
}
// Bascule clair/sombre depuis l'en-tête. Elle passe par applyTheme + savePrefs
// (01-theme.js) pour rester alignée avec l'interrupteur des réglages et avec le
// serveur — deux chemins vers le même réglage, une seule source de vérité.
function toggleThemeQuick(){
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
applyTheme(dark ? 'light' : 'dark');
if(typeof savePrefs === 'function') savePrefs();
}
// Déplie un <details> ET tous ceux qui le contiennent. Depuis que les réglages
// vivent sous un repli unique, ouvrir une section par programme (journal du
// moteur, installation llama.cpp) ne suffit plus : elle s'ouvrait à l'intérieur
// d'un parent fermé, donc invisible.
function openDetails(el){
if(typeof el === 'string') el = document.getElementById(el);
while(el){
if(el.tagName === 'DETAILS') el.open = true;
el = el.parentElement;
}
}
// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
// à cliquer la ligne correspondante dans les réglages (switchTo demande
// confirmation puis relance le moteur).
function onModelSwitch(sel){
const n = parseInt(sel.value, 10);
if(!n) return;
const name = sel.options[sel.selectedIndex].textContent;
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
// pendant les quelques secondes du redémarrage.
switchTo(n, name);
}
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
//
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
// empreinte correspond exactement à la configuration courante. Une configuration
// écrite à la main, héritée d'une image, ou modifiée d'un cran depuis le dernier
// preset ne correspond à AUCUN d'entre eux — et l'en-tête n'affichait alors rien
// alors qu'un modèle était bel et bien chargé. On retombe donc sur le nom du
// fichier de la configuration active.
//
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
// en parallèle (loadAll), aucun ordre n'est garanti.
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
paintModelSwitch();
}
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
function setLoadedModel(name){
MS_MODEL = (name || '').trim();
paintModelSwitch();
}
// Nom lisible d'un fichier de modèle : sans dossier ni extension. Un .gguf
// s'appelle « Qwen3-14B-Instruct-Q4_K_M.gguf » — le chemin et le suffixe
// n'apprennent rien et mangent toute la place.
function modelLabel(path){
const base = path.split(/[\\/]/).pop();
return base.replace(/\.gguf$/i, '');
}
function paintModelSwitch(){
const sel = document.getElementById('model-switch');
if(!sel) return;
const prev = sel.value;
sel.textContent = '';
// Ligne de tête : ce qui est RÉELLEMENT chargé, quand aucun preset ne le
// revendique. Value vide → onModelSwitch l'ignore, elle ne bascule rien.
if(!MS_ACTIVE){
const o = document.createElement('option');
o.value = '';
o.textContent = MS_MODEL ? modelLabel(MS_MODEL) : (MS_PRESETS.length ? 'aucun modèle actif' : 'aucun preset');
o.selected = true;
sel.appendChild(o);
}
MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.textContent = p.name;
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
sel.appendChild(o);
});
sel.disabled = !MS_PRESETS.length;
sel.title = MS_MODEL
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
: 'aucun modèle configuré';
// Une bascule en cours ne doit pas voir sa sélection sauter sous la souris.
if(prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
}
+510 -58
View File
@@ -1,42 +1,83 @@
/* ===== Polices =====================================================
Bricolage Grotesque (titres et texte) + IBM Plex Mono (chiffres, chemins,
code). Servies par Loki (/fonts/, embarquées dans le binaire) et NON par
Google Fonts : une instance locale ou coupée d'internet doit s'afficher
correctement, sans qu'un tiers apprenne qui consulte l'interface.
Inter (interface) + JetBrains Mono (chiffres, chemins, code). Servies par Loki
(/fonts/, embarquées dans le binaire) et NON 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 uniquement (~48 + ~31 Ko) : il couvre le français, y
compris œ et les guillemets. Les symboles de l'UI (↻ ⌫ ☰ 🗑) n'ont jamais été
dessinés par la police d'interface — ils viennent du système, ici comme avant.
swap : le texte s'affiche tout de suite dans la police de repli, puis bascule
— jamais de blanc au chargement. */
@font-face{font-family:'Bricolage Grotesque';src:url('/fonts/bricolage.woff2') format('woff2');font-weight:400 700;font-display:swap;font-style:normal}
@font-face{font-family:'IBM Plex Mono';src:url('/fonts/plexmono-400.woff2') format('woff2');font-weight:400;font-display:swap;font-style:normal}
@font-face{font-family:'IBM Plex Mono';src:url('/fonts/plexmono-500.woff2') format('woff2');font-weight:500;font-display:swap;font-style:normal}
@font-face{font-family:'Inter';src:url('/fonts/inter.woff2') format('woff2');font-weight:100 900;font-display:swap;font-style:normal}
@font-face{font-family:'JetBrains Mono';src:url('/fonts/jetbrainsmono.woff2') format('woff2');font-weight:100 800;font-display:swap;font-style:normal}
/* ===== Palette « Terre » ===========================================
Bruns chauds, vert sauge et argile. Deux variantes — claire par défaut,
sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI passent
par ces variables ; rien d'autre ne code une couleur en dur.
/* ===== Palette « Sober Tech » ======================================
Ardoise et sauge. Deux variantes — claire par défaut, sombre via
data-theme="dark" sur <html>. Toutes les couleurs de l'UI passent par ces
variables ; rien d'autre ne code une couleur en dur.
Rôles : --accent = sauge (sélection, marche, liens) · --warn = argile
(chiffres, mesures, avertissements) · --err = terre cuite (arrêt, danger).
--row-bg / --code-bg sont les deux fonds encastrés d'un panneau. */
⚠️ Les valeurs NOMMÉES viennent des deux planches officielles du projet Stitch
« Loki AI Chat Modern Redesign v1 » — « Official Palette for Loki AI v1 » et
« Official Dark Mode Palette for Loki AI » — pas d'une approximation à l'œil :
clair Main BG #F5F6F8 · Sidebar BG #ECECEC · Primary Accent #95A899
Typography #333333
sombre Deep Navy #0F172A · Slate Surface #1E293B · Sage Accent #84A98C
Le reste (carte de réponse, carte de saisie, piste de jauge, bulle sombre) est
relevé sur les écrans de la maquette, qui posent une hiérarchie de surfaces
que l'intuition inverse volontiers :
barre latérale la plus SOMBRE des trois #ECECEC / #0F172A
fil la surface de référence #F5F6F8 / #1E293B
carte s'ÉCARTE du fil #EAEBEE / #2A3646
saisie la plus claire #FBFBFD / #1B2436
En clair la carte de réponse est donc plus sombre que le fil (et non blanche),
en sombre plus claire : dans les deux cas elle se détache en s'éloignant du
fond, jamais en allant vers le blanc.
La bulle de l'utilisateur ne suit PAS l'accent : sauge en clair (#7EA58A),
ardoise en sombre (#334257) — la maquette réserve le vert aux jauges dès que
le fond est sombre.
Rôles : --accent = sauge (sélection, marche, liens, bulle utilisateur) ·
--warn = ambre (chiffres, mesures, avertissements) · --err = rouille (arrêt,
danger). --row-bg / --code-bg sont les deux fonds encastrés d'un panneau.
--code-bg est nettement plus soutenu que --bubble : un bloc de code posé DANS
la carte de réponse doit se lire comme un encadré, pas se fondre dedans.
--on-accent : ce qui s'écrit SUR un aplat de sauge. Il change avec la
variante — blanc sur la sauge foncée du thème clair, ardoise sur la sauge
claire du thème sombre — sinon un des deux thèmes écrit du texte à 2:1 de
contraste sur la bulle utilisateur et le bouton d'envoi. */
:root{color-scheme:light;
--font:'Bricolage Grotesque',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--bg:#f3efe8;--panel:#ffffff;--bubble:#efe9df;--border:rgba(44,38,34,.12);
--text:#2c2622;--dim:#7a7069;
--accent:#5f7f52;--accent-bg:rgba(95,127,82,.12);
--row-bg:#f7f4ee;--row-bg-on:#f4f1ea;
--ok:#5f7f52;--warn:#a9662f;--err:#c05a3f;--mag:#a9662f;
--code-bg:#f7f4ee;--danger-bg:#f8e9e4;--warn-bg:#f6efe4;
--add:#5f7f52;--add-bg:rgba(95,127,82,.12);--del:#c05a3f;--del-bg:rgba(192,90,63,.12);
--shadow:0 1px 2px rgba(44,38,34,.06)}
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--bg:#f5f6f8;--panel:#fbfbfd;--bubble:#eaebee;--border:rgba(51,51,51,.11);
--text:#333333;--dim:#6b7280;
--accent:#587062;--accent-bg:rgba(149,168,153,.20);--on-accent:#ffffff;--on-err:#ffffff;
--accent-fill:#95a899;--accent-soft:#95a899;
--user-bg:#95a899;--on-user:#ffffff;
--side:#ececec;--row-bg:#f0f0f1;--row-bg-on:#e0e0e2;--track:#dadbdf;
--ok:#587062;--warn:#a86a2a;--err:#c0503f;--mag:#a86a2a;
--code-bg:#e2e3e7;--danger-bg:#f5e3e0;--warn-bg:#f2ece1;
--add:#587062;--add-bg:rgba(149,168,153,.20);--del:#c0503f;--del-bg:rgba(192,80,63,.12);
--shadow:0 1px 2px rgba(15,23,42,.05);--shadow-lift:0 4px 16px rgba(15,23,42,.08);
--shadow-card:0 2px 10px rgba(15,23,42,.06),0 1px 3px rgba(15,23,42,.04)}
/* « Deep Dark » : fond #0F172A, cartes #1E293B. */
[data-theme="dark"]{color-scheme:dark;
--bg:#211d1a;--panel:#2b2622;--bubble:#38312a;--border:rgba(236,229,221,.12);
--text:#ece5dd;--dim:#a99d92;
--accent:#9ab68c;--accent-bg:rgba(154,182,140,.15);
--row-bg:#332d28;--row-bg-on:#38312a;
--ok:#9ab68c;--warn:#d2a07a;--err:#c9705c;--mag:#d2a07a;
--code-bg:#211d1a;--danger-bg:#3a2621;--warn-bg:#332b23;
--add:#9ab68c;--add-bg:rgba(154,182,140,.15);--del:#c9705c;--del-bg:rgba(201,112,92,.15);
--shadow:none}
--bg:#1e293b;--panel:#1b2436;--bubble:#2a3646;--border:rgba(148,163,184,.16);
--text:#e2e8f0;--dim:#94a3b8;
--accent:#a3c4ab;--accent-bg:rgba(132,169,140,.22);--on-accent:#0f172a;--on-err:#0f172a;
--accent-fill:#84a98c;--accent-soft:#84a98c;
--user-bg:#334259;--on-user:#e8eef5;
--side:#0f172a;--row-bg:#1a2334;--row-bg-on:#232c3f;--track:#232a3a;
--ok:#a3c4ab;--warn:#d9a55f;--err:#e0705c;--mag:#d9a55f;
--code-bg:#16202f;--danger-bg:#3a1f1c;--warn-bg:#33291b;
--add:#a3c4ab;--add-bg:rgba(132,169,140,.22);--del:#e0705c;--del-bg:rgba(224,112,92,.16);
--shadow:none;--shadow-lift:0 4px 16px rgba(0,0,0,.35);
--shadow-card:0 2px 12px rgba(0,0,0,.28),0 1px 3px rgba(0,0,0,.20)}
/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien,
résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors
de notre charte et impossible à styler : on le coupe. Nos états :active/:focus
@@ -119,7 +160,6 @@ option,optgroup{background:var(--panel);color:var(--text)}
.msg{max-width:92%}
#chat .msg.assistant,#chat .msg.reasoning{max-width:100%}
#inputbar{padding:8px;gap:4px}
#send,#stop{padding:0 12px}
/* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et
ça prend de la place. Elle reste visible sur desktop. */
#sendhint{display:none}
@@ -465,7 +505,6 @@ button:disabled{opacity:.5;cursor:not-allowed}
@media (max-width:720px){
.side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
}
[data-hide-side="1"] .side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
.sidehead{margin-bottom:10px}
/* --- En-tête de section --- */
@@ -601,24 +640,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
data-hide-reasoning : masque les bulles de raisonnement
data-hide-tools : masque les bulles d'appel d'outil
data-fold-tools : les garde repliées (déplia­bles au clic)
data-hide-side : barre latérale en tiroir, même sur ordinateur
Quand raisonnement et outils sont masqués, l'activité reste signalée par
l'indicateur de frappe (.msg.typing) maintenu pendant tout le tour. */
[data-hide-reasoning="1"] #chat .msg.reasoning{display:none}
[data-hide-tools="1"] #chat .msg.tool{display:none}
/* Barre latérale en tiroir sur TOUTES les tailles d'écran : mêmes règles que
la version mobile, activées par l'attribut au lieu de la largeur. */
[data-hide-side="1"] .side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left));border-right:1px solid var(--border)}
@media (min-width:721px){[data-hide-side="1"] .side{width:380px;max-width:380px}}
/* Le bouton ☰ est posé au-dessus du fil : on dégage le haut de la
conversation pour qu'il ne recouvre aucun message. */
@media (min-width:721px){html[data-hide-side="1"] #chat{padding-top:60px}}
[data-hide-side="1"] .side.open{transform:translateX(0)}
[data-hide-side="1"] #menubtn{display:block}
[data-hide-side="1"] #lc-chip:not([hidden]){display:block}
[data-hide-side="1"] #backdrop.open{display:block}
[data-hide-side="1"] body.drawer-open #menubtn,[data-hide-side="1"] body.drawer-open #lc-chip{display:none}
[data-hide-side="1"] .main{width:100%}
/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui
peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli.
width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0
@@ -647,7 +672,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
#sendhint.waiting{opacity:1;color:var(--dim)}
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
#input:focus{outline:none;border-color:var(--accent)}
#send{padding:0 20px}
.muted{color:var(--dim);font-size:13px}
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)}
#toast.show{opacity:1}
@@ -679,7 +703,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
/* PAS de font-size < 16px ici : iOS Safari zoome à la sélection du champ. On
garde 16px (hérité de la règle anti-zoom) et on compacte via hauteur/padding. */
#input{min-height:34px;max-height:120px;padding:6px 9px}
#send,#stop{padding:0 14px}
#composer .muted{display:none}
#ctxmeta{padding:3px 10px 0}
h1{font-size:15px}
@@ -974,12 +997,27 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px}
.mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
/* Version du moteur : même pastille, mais SANS majuscules forcées — un numéro
de build et un hash de commit se recopient et se comparent à l'œil, et
« AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */
.eng-ver{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--text)}
.mcp-tools{font-size:10px;color:var(--accent)}
.mcp-err{font-size:10px;color:var(--err)}
.mcp-switch{flex:none}
.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5}
.mcp-edit:hover{opacity:1;color:var(--accent)}
/* Liste des outils MCP dans la modale */
.mcp-actions{display:flex;gap:6px;margin-top:6px}
.mcp-cat-tabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}
.mcp-cat{font-size:11px;padding:2px 9px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer}
.mcp-cat:hover{border-color:var(--accent);color:var(--text)}
.mcp-cat.on{border-color:var(--accent);color:var(--accent)}
.mcp-cat-list{display:flex;flex-direction:column;gap:4px;max-height:52vh;overflow:auto}
.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel)}
.mcp-cat-row:hover{border-color:var(--accent)}
.mcp-cat-desc{font-size:11px;color:var(--dim);margin-top:2px;line-height:1.35}
.mcp-cat-cmd{font-size:10px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:340px}
.mcp-cat-use{flex:none}
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px}
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer}
.mcp-tool-row:hover{background:var(--panel)}
@@ -1077,9 +1115,6 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
bordure, seule la saisie change (curseur). */
#input:focus{border-color:transparent}
#send,#stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0}
#send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600}
#send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)}
/* Pied de carte : c'est LUI qui porte l'arrondi bas (26 px, comme en haut) et
qui clippe la jauge. Un bandeau de 7 px ne peut pas dessiner un rayon de 26 px
(le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte
@@ -1290,10 +1325,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
/* Action principale en sauge : c'est la couleur d'action de la charte, et elle
se distingue enfin du texte (le bouton reprenait --text, donc un brun sombre
indistinct du reste). L'arrêt, lui, reste en terre cuite. */
#send{background:var(--accent);border-color:var(--accent);color:#fff}
#send:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:#fff;opacity:.88}
#stop{background:var(--err);border-color:var(--err);color:#fff}
#stop:hover{background:var(--err);border-color:var(--err);color:#fff;opacity:.88}
/* --- Terre : largeur de lecture --------------------------------------
La mesure était plafonnée à 760 px et centrée sur la fenêtre : sur un écran
@@ -1409,7 +1440,14 @@ html[data-files="1"] #files{display:flex}
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border)}
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border);
display:flex;align-items:center;gap:6px;flex-wrap:wrap}
/* Détour « hors discussion » : discret, en pied de panneau — c'est une sortie de
secours pour les fichiers d'avant le rangement par discussion, pas une vue
qu'on propose au même rang que la discussion ouverte. */
.files-scope{margin:0;padding:2px 6px;min-height:0;font-family:var(--mono);font-size:10px;
background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--dim)}
.files-scope:hover{color:var(--text);background:var(--row-bg-on)}
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
.files-row.busy{opacity:.55;pointer-events:none}
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand
@@ -1434,3 +1472,417 @@ html[data-files="1"] #files-btn{color:var(--accent)}
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
box-shadow:var(--shadow);margin:0}
}
/* =====================================================================
===== Refonte « Sober Tech » ========================================
=====================================================================
Dernière section du fichier, donc dernier mot de la cascade : elle porte la
coque de la refonte (barre d'en-tête, zones de la barre latérale, moniteur
machine) et redresse les quelques règles historiques qu'elle contredit.
Trois principes, tirés de la maquette :
1. Le fil est la seule chose qui compte : tout le reste est atténué, sans
cadre superflu, et la barre latérale s'escamote d'un clic.
2. Les données machine sont TOUJOURS visibles mais jamais au premier plan —
un pied de barre latérale, trois jauges, pas un tableau.
3. Aucun contour permanent sur les boutons : l'affordance vient du survol
(fond qui apparaît, léger enfoncement au clic).
===================================================================== */
/* --- Coque ---------------------------------------------------------- */
.side{background:var(--side);display:flex;flex-direction:column;gap:0;padding:0;overflow:hidden}
/* Les trois zones de la barre : en-tête fixe, contenu qui défile, moniteur
posé en pied. Sans ça, la liste des discussions poussait les jauges hors de
l'écran dès la dixième discussion. */
.sidehead{flex:none;display:flex;align-items:center;gap:8px;margin:0;
padding:14px 14px 10px;padding-top:calc(14px + env(safe-area-inset-top))}
#side-scroll{flex:1;min-height:0;overflow-y:auto;padding:0 0 12px;
scrollbar-gutter:stable;display:flex;flex-direction:column;gap:0}
#side-perf{flex:none;border-top:1px solid var(--border);padding:12px 14px;
padding-bottom:calc(12px + env(safe-area-inset-bottom))}
@media (max-width:720px){
.side{padding:0}
#side-scroll{padding-left:calc(12px + env(safe-area-inset-left))}
.sidehead{padding-left:calc(14px + env(safe-area-inset-left))}
}
/* Escamotage sur grand écran : la barre disparaît, le fil reprend la place.
L'état est persisté (localStorage) et posé sur <html> avant le rendu, comme
le thème — sinon la barre apparaît puis se referme sous les yeux. */
@media (min-width:721px){
html[data-side="0"] .side{display:none}
html[data-side="0"] #sidetoggle svg{transform:none}
}
#sidetoggle svg{transform:scaleX(-1)}
/* --- Barre d'en-tête du fil ----------------------------------------- */
/* Elle porte ce qui décrit la conversation en cours : de quoi on parle, et avec
QUEL modèle. Le choix du modèle vivait au fond de la barre latérale, dans une
liste de presets — trois clics et un défilement pour une action qu'on fait
plusieurs fois par session. */
#topbar{flex:none;display:flex;align-items:center;gap:10px;padding:10px 16px;
padding-top:calc(10px + env(safe-area-inset-top));
border-bottom:1px solid var(--border);background:var(--bg);z-index:5}
#topbar-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
font-size:13.5px;font-weight:500;color:var(--text)}
#topbar .iconbtn{width:30px;height:30px;flex:none;display:inline-flex;align-items:center;
justify-content:center;border:0;border-radius:9px;background:transparent;color:var(--dim);
cursor:pointer;margin:0;padding:0;transition:background .15s,color .15s,transform .1s}
#topbar .iconbtn:hover{background:var(--row-bg-on);color:var(--text)}
#topbar .iconbtn:active{transform:scale(.92)}
#topbar .iconbtn svg{display:block}
/* Sélecteur de modèle : le libellé n'est PAS une étiquette flottante mais le nom
du preset actif, lisible d'un coup d'œil. */
#model-switch{max-width:230px;font:inherit;font-size:12.5px;color:var(--text);
background:var(--panel);border:1px solid var(--border);border-radius:999px;
padding:6px 30px 6px 12px;cursor:pointer;appearance:none;
background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),linear-gradient(135deg,var(--dim) 50%,transparent 50%);
background-position:calc(100% - 15px) 52%,calc(100% - 11px) 52%;
background-size:4px 4px,4px 4px;background-repeat:no-repeat}
#model-switch:hover{border-color:var(--accent)}
#model-switch:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
@media (max-width:720px){
#topbar{padding-left:56px} /* la place du bouton ☰, en position fixe */
#model-switch{max-width:130px}
}
/* --- Discussions ----------------------------------------------------- */
/* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne.
Le reste (réglages) descend sous un unique repli. */
#conv-search{width:calc(100% - 24px);font:inherit;font-size:12.5px;color:var(--text);
background:var(--panel);border:1px solid var(--border);border-radius:9px;
padding:7px 11px;margin:0 12px 8px}
#conv-search::placeholder{color:var(--dim)}
#conv-search:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
.side .preset{padding:11px 14px;margin:0;gap:10px;border-radius:0;
border:0;border-bottom:1px solid var(--border)}
.side .preset .preset-name{font-size:13px;font-weight:450;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;word-break:normal;overflow-wrap:normal}
.side .preset.active .preset-name{font-weight:550}
/* Heure de la dernière activité : à DROITE du titre, sur la même ligne, en
monospace pour que les colonnes s'alignent d'une ligne à l'autre.
⚠️ Sélecteur en TROIS classes : `.preset>span` (plus haut) pose flex:1 sur
tout span enfant d'une ligne — l'heure prenait donc la MOITIÉ de la ligne et
le titre était tronqué au premier mot. */
.side .preset .conv-when{flex:none;font-family:var(--mono);font-size:10.5px;
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
/* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette
seule ligne, pour que la barre latérale de la maquette (discussions +
machine) reste la vue par défaut. */
#side-settings{margin-top:8px;border-top:1px solid var(--border)}
#side-settings>summary{height:38px}
#side-settings>.sidegroup{margin-top:2px}
/* --- Moniteur machine (pied de barre latérale) ----------------------- */
.perf-head{display:flex;align-items:center;gap:8px;margin-bottom:11px;
font-size:14px;font-weight:600;color:var(--text);letter-spacing:-.01em}
.perf-row{margin:0 0 14px}
.perf-l{display:flex;align-items:baseline;gap:8px;font-size:12.5px;color:var(--text);margin-bottom:6px}
.perf-l b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
font-weight:400;color:var(--text)}
.perf-l .perf-pct{font-size:12.5px;color:var(--text);font-variant-numeric:tabular-nums}
/* La jauge : 4 px, coins ronds, remplissage sauge. Elle passe à l'ambre puis au
rouille quand la machine sature — c'est là que l'information devient utile. */
.perf-track{height:7px;border-radius:999px;background:var(--track);overflow:hidden}
.perf-fill{height:100%;width:0;border-radius:999px;background:var(--accent-fill);
transition:width .4s ease,background .4s ease}
.perf-fill.hot{background:var(--warn)}
.perf-fill.max{background:var(--err)}
/* --- Boutons : un cadre, et une micro-interaction au survol -----------
La refonte les avait voulus « sans contour » : contour transparent, et un
aplat pris sur --row-bg. Or c'est exactement le fond des blocs qui les
contiennent (.slist, .rowbody) : un bouton posé dessus n'avait plus ni trait
ni écart de valeur, et « démarrer / redémarrer / arrêter » ou « mettre à jour
le moteur » se lisaient comme du texte, pas comme des commandes.
On revient donc au cadre — celui d'avant la refonte : trait de 1 px et aplat
un ton AU-DESSUS du bloc, pour que le bouton se détache dans les deux cas.
La micro-interaction, elle, reste : elle est bonne, elle manquait juste de
quelque chose à animer.
⚠️ Ne concerne pas les boutons-icônes de la saisie et de l'en-tête (#send,
#stop, #attach, #menubtn…) : ils posent `border:0` PLUS BAS dans la feuille
et gardent leur allure nue, qui est la bonne pour un pictogramme seul. */
button{background:var(--panel);border-color:var(--border);border-radius:9px;
transition:background .15s ease,color .15s ease,border-color .15s ease,transform .08s ease}
button:hover{background:var(--row-bg);border-color:var(--dim);color:var(--text)}
button:active:not(:disabled){transform:translateY(1px)}
.side .preset,.slist,#conv-search{transition:background .15s ease,border-color .15s ease}
/* --- Fil : la réponse redevient une carte --------------------------- */
/* La maquette encadre la réponse de l'IA : sur un fond de fil coloré, du texte
nu flotte sans ancrage, et la frontière entre deux réponses se perd. */
#chat{padding-top:22px}
#chat .msg.assistant{background:var(--bubble);border:0;
border-radius:10px;padding:15px 17px;max-width:100%;box-shadow:var(--shadow-card)}
#chat .msg.assistant.errmsg{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 45%,transparent)}
/* Question : aplat de sauge, coin bas-droit rentré. --on-accent garantit le
contraste dans les DEUX variantes. */
#chat .msg.user{background:var(--user-bg);color:var(--on-user);border-radius:10px;
padding:13px 16px;max-width:62%;box-shadow:var(--shadow-card)}
#chat .msg.user .label{color:var(--on-user);opacity:.75}
#chat .msg.user .body a{color:var(--on-user);text-decoration:underline}
#chat .msg.user code{background:rgba(255,255,255,.18);color:inherit}
[data-theme="dark"] #chat .msg.user code{background:rgba(15,23,42,.22)}
/* Les pastilles de fichier d'une question sont posées SUR l'aplat de sauge :
sans fond propre, elles s'y fondaient. */
#chat .msg.user .msg-files .chip-file{background:var(--panel);color:var(--text);border-color:transparent}
/* --- Saisie : la pilule de la maquette ------------------------------ */
/* Le bouton d'envoi devient une pastille ronde à flèche : c'est la forme de la
maquette, et elle laisse toute la largeur au texte. Le mot « Envoyer » reste
porté par aria-label et par l'infobulle — l'action n'est pas moins nommée
pour un lecteur d'écran, elle l'est moins à l'écran. */
/* Les QUATRE boutons de la barre de saisie — joindre, fichiers, envoyer,
arrêter — sont un seul et même objet : 34x34, coins doux (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 les secondaires, sauge pour
l'envoi, rouille pour l'arrêt.
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
taille, sa forme et son alignement d'une couche différente. */
#inputbar #attach,#inputbar #files-btn,#send,#stop{
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
display:inline-flex;align-items:center;justify-content:center;align-self:center;
border:0;border-radius:10px;background:transparent;color:var(--dim);
transition:background .15s ease,color .15s ease,transform .08s ease}
/* Envoyer / arrêter : un seul des deux à la fois, décidé par data-busy (posé par
syncSendBtn). C'est la feuille qui garde la main sur `display` — un style
inline y substituait `inline-block`, et le centrage flex tombait avec lui. */
#stop{display:none}
html[data-busy="1"] #send{display:none}
html[data-busy="1"] #stop{display:inline-flex}
#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px}
#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)}
html[data-files="1"] #files-btn{color:var(--accent)}
#send{color:var(--accent)}
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
#send:disabled{background:transparent;color:var(--dim);opacity:.45}
#stop{color:var(--err)}
#stop:hover{background:var(--danger-bg);color:var(--err);opacity:1}
#inputbar #attach:active,#inputbar #files-btn:active,#send:active:not(:disabled),#stop:active{transform:scale(.92)}
/* Les outils de la saisie (joindre, fichiers, envoyer) montent DANS la pilule,
à droite du texte : c'est là qu'on les cherche, et le pied de carte redevient
ce qu'il doit être — une jauge de contexte, rien d'autre. */
#inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow-card);
border-radius:14px 14px 0 0}
#ctxmeta{border-radius:0 0 14px 14px}
#ctxmeta{padding-bottom:12px}
#ctxtrack{border-radius:999px}
/* --- Coque à plat ---------------------------------------------------
La direction précédente posait deux CARTES flottantes séparées par une
gouttière ; la maquette est bord à bord : barre latérale collée au bord,
simple filet de séparation, fil qui occupe tout le reste. On neutralise donc
ici les règles de coque de la section « Terre » plutôt que de les réécrire
plus haut — la cascade fait le tri, et l'historique reste lisible. */
@media (min-width:721px){
body{padding:0;gap:0}
html[data-pwa="1"] body{padding-top:env(safe-area-inset-top)}
.side{width:300px;border-right:1px solid var(--border);border-radius:0;
box-shadow:none;background:var(--side);overflow:hidden}
/* Dans la barre, plus de cartes : des lignes posées sur le fond. Deux niveaux
de cadre imbriqués (carte de section + carte de liste) pour une liste de
titres, c'était deux de trop. */
#side-scroll>details,#side-scroll>.sidegroup{background:transparent;box-shadow:none;
border-radius:0;padding:0;margin:0}
#side-scroll>details>summary{height:34px}
.side .slist{background:transparent;border-color:transparent;border-radius:12px}
.side #side-settings .slist{background:var(--row-bg);border-radius:12px}
/* Le fil n'est plus une carte : il EST la page. */
.main{background:transparent;border-radius:0;box-shadow:none;overflow:visible}
#composer{background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))}
#inputbar,#ctxmeta{background:var(--panel);border-color:var(--border)}
#input{background:transparent}
}
/* Ligne de discussion : les actions (renommer, supprimer) sont posées EN
SURIMPRESSION à droite plutôt qu'en flux. En flux, elles réservaient leur
largeur en permanence — invisibles mais présentes — et le titre était tronqué
au tiers de la place disponible. */
.side .preset{position:relative}
.side .preset .conv-acts{position:absolute;right:6px;top:50%;transform:translateY(-50%);
margin:0;background:var(--row-bg-on);border-radius:9px;padding:1px}
.side .preset.active .conv-acts{background:var(--row-bg)}
.side .preset .preset-info{overflow:hidden}
/* La sélection est un aplat, pas un cadre : un liseré autour d'une ligne de
liste alourdit là où un fond suffit à dire « c'est celle-ci ». */
.side .preset.active{background:var(--row-bg-on);border-color:transparent}
.side .preset.active .preset-name{color:var(--text)}
/* --- Téléphone : un seul bouton de barre latérale -------------------- */
/* Le ☰ flottant ouvre déjà le tiroir ; garder EN PLUS l'icône d'escamotage de
l'en-tête donnait deux boutons voisins pour la même action, et grignotait la
place du titre sur un écran qui n'en a pas. */
@media (max-width:720px){
/* Sélecteur en DEUX id : `#topbar .iconbtn` (plus haut) pose déjà un display,
et il l'emporterait sur un simple `#sidetoggle`. */
#topbar #sidetoggle{display:none}
#topbar{padding-left:52px;padding-right:10px;gap:6px}
#topbar-title{font-size:13px}
}
/* Le ☰ suit la charte des boutons : pas de contour, un fond qui apparaît au
survol. Il flotte au-dessus de l'en-tête, il doit donc en reprendre le fond
pour ne pas laisser voir le fil défiler dessous. */
#menubtn{background:var(--bg);border:0;color:var(--dim);border-radius:10px;
width:34px;height:34px;font-size:16px;top:calc(11px + env(safe-area-inset-top));left:9px}
#menubtn:hover{background:var(--row-bg-on);color:var(--text)}
#lc-chip{border-radius:10px;height:34px;top:calc(11px + env(safe-area-inset-top));left:calc(50px + env(safe-area-inset-left))}
/* Jauges : la piste doit se VOIR, sinon une barre presque vide ressemble à un
trait souligné sous l'intitulé. Valeurs relevées sur la maquette. */
.perf-track{background:var(--track)}
/* Une liste déroulante posée directement dans une ligne de modale n'a pas de
garde-fou de largeur (contrairement à .pe-row-c) : une valeur très longue —
un chemin de destination, par exemple — débordait sous l'intitulé. */
.pe-row>.pe-selc{max-width:62%}
/* Marque et attribution du fork empilées, pastille d'état à leur droite. */
.sidebrand{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
.sidebrand .brand-fork{margin:0;font-size:10px;line-height:1.3}
/* Blocs de code du fil : encadré franc dans la carte de réponse — fond plus
soutenu, filet net et coins alignés sur ceux de la carte. Le bouton « copier »
reprend le fond du bloc pour ne pas flotter sur le texte. */
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{
background:var(--code-bg);border:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
#chat .msg.assistant .body code{background:var(--code-bg);
border:1px solid color-mix(in srgb,var(--text) 7%,transparent);border-radius:5px}
#chat .msg.assistant .body pre code{border:0;background:transparent}
/* --- Titres de la barre latérale ------------------------------------
La maquette n'a qu'un style de titre : casse normale, demi-gras, taille du
texte courant (« Performance »). Le monospace en capitales de la charte
précédente y jurait, et faisait crier des libellés qui n'ont rien à dire. */
/* Le panneau Fichiers porte les mêmes titres que la barre latérale : un seul
style de titre dans toute l'application, celui de la maquette. */
.stitle{font-family:var(--font);text-transform:none;letter-spacing:-.01em;
font-size:13.5px;font-weight:600;color:var(--text)}
.side-sep{font-family:var(--font);text-transform:none;letter-spacing:0;
font-size:12px;font-weight:600;color:var(--dim)}
#side-scroll>details>summary,#side-settings>summary{padding:0 14px}
#side-settings{margin-top:0;border-top:1px solid var(--border)}
#side-settings>.sidegroup{padding:0 12px}
/* La liste elle-même n'a pas de gouttière : ses lignes touchent les deux bords,
comme dans la maquette. Le filet du bas de la dernière ligne fait la
séparation avec ce qui suit. */
#conv-list{margin:0}
.side .preset:hover{background:var(--row-bg)}
.side .preset .conv-acts{right:8px}
/* Message d'absence de matériel : même famille que les jauges, en plus discret.
(La sous-ligne chiffrée, elle, a quitté l'affichage — le détail est en
infobulle, comme dans la maquette.) */
.perf-sub{font-size:11.5px;color:var(--dim)}
/* --- Carte de saisie : le trait sauge de la maquette -----------------
C'est le seul contour coloré de l'écran, et il désigne l'endroit où l'on
écrit. ⚠️ Posé EN FIN DE FEUILLE : la règle de coque plus haut
(`#inputbar,#ctxmeta{border-color:var(--border)}`) reprenait sinon la main et
le cadre retombait au gris commun.
La flèche d'envoi prend la MÊME sauge que la maquette — c'est un aplat plein
de 18 px, pas du texte. */
@media (min-width:721px){
#inputbar,#ctxmeta{border-color:var(--accent-fill)}
#composer:focus-within #inputbar,#composer:focus-within #ctxmeta{border-color:var(--accent)}
}
#inputbar,#ctxmeta{border-color:var(--accent-fill)}
#send{color:var(--accent-fill)}
#send:hover:not(:disabled){color:var(--accent)}
/* --- Échelle typographique des planches ------------------------------
« Typography: Inter » — Bold 24px pour les titres, Regular 16px pour le corps,
Medium 14px pour le texte secondaire. On l'applique là où elle a un sens
(le fil, la barre latérale) plutôt qu'en gonflant toute l'interface : les
panneaux de réglages sont des formulaires denses, pas du texte de lecture. */
#chat .msg .body{font-size:16px}
#chat .msg.user .body,#chat .msg.assistant .body{line-height:1.6}
#input{font-size:16px}
.side .preset .preset-name{font-size:14px}
.side .preset .conv-when{font-size:13px}
.perf-head{font-size:16px;font-weight:700}
.perf-l,.perf-l .perf-pct{font-size:14px;font-weight:500}
.stitle{font-size:14px;font-weight:600}
/* Marque : « Loki v0.9.4 » en tête de colonne, à l'échelle des titres. */
#brand svg{height:20px}
#ver{font-size:13px;font-weight:500;margin-left:8px}
/* --- Boutons-icônes : une seule géométrie --------------------------
Toutes ces commandes portaient un GLYPHE DE TEXTE, donc une taille et une
assiette décidées par la police du système. En SVG, elles se centrent
normalement — encore faut-il que leur bouton soit une boîte flex et non une
ligne de texte. */
/* ⚠️ On NE touche PAS à `display` ici : plusieurs de ces boutons s'affichent et
se masquent par ce biais (#menubtn et .preset-add sont en display:none au
repos, #scrollbtn aussi). Une règle de centrage qui pose display:inline-flex
les rendrait visibles en permanence — le ☰ s'est ainsi retrouvé par-dessus
l'en-tête sur grand écran. Le centrage passe donc par le rembourrage, que la
taille connue de chaque bouton rend exact. */
.modal-x,.mcp-edit,#files-refresh,#files-close,
.files-acts .iconbtn,.conv-acts .iconbtn{
display:inline-flex;align-items:center;justify-content:center;line-height:1}
#menubtn{padding:8px;line-height:0}
#scrollbtn{padding:9px;line-height:0}
.preset-add{padding:5px;line-height:0}
#menubtn svg,#scrollbtn svg,.preset-add svg{display:block;margin:0 auto}
.modal-x svg,.mcp-edit svg,#files-refresh svg,#files-close svg,.iconbtn svg{
display:block;width:auto;height:auto}
/* La pastille du fil d'Ariane des fichiers n'est plus un caractère : elle a la
largeur de son dessin. */
.files-ic{width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center}
.files-ic svg{display:block}
.files-row.isdir .files-ic{color:var(--accent)}
/* « Vider la discussion » : icône + libellé, alignés comme un seul bloc. */
#chat-actions-clear,.row>button>svg{vertical-align:-2px}
.row>button:has(svg){display:inline-flex;align-items:center;gap:7px}
/* Le « + » des sections : même carré que les autres commandes de la barre. */
.preset-add{width:26px;height:26px;border-radius:8px}
/* Étiquettes de groupe : même traitement que les titres de section — la
maquette n'a qu'un style de titre, et le monospace en capitales de la charte
précédente criait au milieu des formulaires. */
.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{
font-family:var(--font);text-transform:none;letter-spacing:0;
font-size:12.5px;font-weight:600;color:var(--dim)}
/* --- Discussions et moniteur : le gabarit de carte des réglages ------
La maquette posait ces deux blocs à plat, pleine largeur. Dans l'application
réelle ils voisinent avec les sections de réglages, qui sont des CARTES : deux
traitements pour deux listes côte à côte, et la barre latérale se lisait comme
deux morceaux collés. On aligne donc sur la carte, qui est la forme dominante
de la colonne. */
#side-scroll{padding:0 12px 12px}
#side-scroll>details>summary,#side-settings>summary{padding:0 2px}
/* ⚠️ La gouttière INTÉRIEURE de la carte n'est pas décorative. En la mettant à
zéro (le retrait extérieur venant désormais de #side-scroll), les lignes
allaient d'un bord à l'autre : le chevron de chaque section tombait dans le
congé de 20 px, donc il en débordait visuellement sur la première et la
dernière ligne, et les filets de séparation venaient buter dans la courbe.
Le rayon paraissait « trop fort » alors que c'est le contenu qui n'avait plus
de quoi s'en écarter. On reprend le retrait d'avant la refonte (16 px), qui
laisse au congé la place de tourner à vide. */
#side-settings>.sidegroup{padding:2px 16px}
@media (max-width:720px){
#side-scroll{padding-left:calc(12px + env(safe-area-inset-left))}
}
#conv-search{width:100%;margin:0 0 8px}
/* La liste EST la carte, au MÊME gabarit que .sidegroup (les sections de
réglages) : fond de panneau, rayon de 20, ombre discrète. Les lignes sont
rognées par overflow au lieu d'arrondir la première et la dernière à la main. */
#conv-list{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
overflow:hidden;margin:0}
.side .preset{border-radius:0;margin:0;padding:10px 12px}
.side .preset:not(:last-child){border-bottom:1px solid var(--border)}
.side .preset:last-child{border-bottom:0}
/* Sélection : bande pleine largeur DANS la carte — un aplat suffit, un cadre
ferait un second contour à l'intérieur du premier. */
.side .preset.active{background:var(--row-bg-on)}
.side .preset:hover:not(.active){background:color-mix(in srgb,var(--text) 4%,transparent)}
.side .preset .conv-acts{background:var(--row-bg-on)}
.side .preset.active .conv-acts{background:var(--row-bg-on)}
.conv-empty{padding:12px}
/* Moniteur machine : titre en étiquette de groupe, jauges dans une carte — même
couple que « Paramètres » + sa liste dans les réglages. */
#side-perf{padding:12px 12px 12px;padding-bottom:calc(12px + env(safe-area-inset-bottom))}
.perf-head{margin:0 2px 7px;font-size:12.5px;font-weight:600;color:var(--dim)}
.perf-body{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
padding:14px 15px 3px}
/* ⚠️ Pas de remise à zéro sur :last-child — les jauges vivent dans DEUX
conteneurs (les cartes graphiques, puis la mémoire vive). La dernière jauge
de chaque conteneur s'y voyait retirer sa marge, et la mémoire vive venait
se coller à la VRAM. C'est le rembourrage bas de la carte qui absorbe la
dernière marge. */
.perf-row{margin:0 0 12px}
.perf-sub{padding:0 0 10px}
+46 -26
View File
@@ -546,51 +546,59 @@ func handleAPIKey(w http.ResponseWriter, r *http.Request) {
_ = serviceAction("restart")
}
}
k := readAPIKey()
k, _ := effectiveAPIKeyErr()
sendJSON(w, 200, map[string]any{
"ok": true,
"set": k != "",
"key": k,
"masked": maskAPIKey(k),
"port": LLMPort(),
"host": localIP(),
// Accès OpenAI PUBLIC via ajean.link (passthrough SNI, VPS aveugle) : si
// activé, l'URL publique est https://<machine>.oai.ajean.link/v1.
"oai_public": oaiPublicEnabled(),
"machine": machineID(),
// L'adresse à coller dans un client tiers. Calculée côté serveur (voir
// web_public_url.go) : adresse publique enregistrée, sinon l'origine par
// laquelle cette requête est arrivée — donc le domaine ou l'IP que
// l'utilisateur a réellement tapés.
"url": oaiBaseURL(r) + "/v1",
"public_url": publicURL(),
})
}
// handleOAIPublic pilote le drapeau d'accès OpenAI public (exposition via
// ajean.link). GET renvoie l'état ; POST {enabled} l'active/coupe en direct
// (aucun redémarrage : le démux du tunnel relit le drapeau à chaque connexion).
func handleOAIPublic(w http.ResponseWriter, r *http.Request) {
// handlePublicAddress lit et enregistre l'adresse publique de cette instance —
// le nom de domaine ou l'IP par lesquels on l'atteint.
//
// GET → {ok, url, set}
// POST {url:"ia.exemple.fr"} → {ok, url} normalisée, ou 400 avec la raison
//
// Elle remplace l'ancien interrupteur d'exposition via le relais de l'amont :
// ce chemin exigeait un jeton de relais que ce fork ne permet plus d'obtenir, et
// l'interrupteur ne pouvait donc qu'échouer. Aucun redémarrage ici — c'est un
// libellé d'affichage, pas un réglage du moteur.
func handlePublicAddress(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Enabled *bool `json:"enabled"`
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "requête illisible"})
return
}
clean, err := setPublicURL(req.URL)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if req.Enabled != nil {
if err := setOAIPublic(*req.Enabled); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
}
sendJSON(w, 200, map[string]any{"ok": true, "url": clean, "set": clean != ""})
return
}
sendJSON(w, 200, map[string]any{
"ok": true,
"enabled": oaiPublicEnabled(),
"machine": machineID(),
})
u := publicURL()
sendJSON(w, 200, map[string]any{"ok": true, "url": u, "set": u != ""})
}
// localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du
// trafic sortant), ou "localhost" à défaut. Sert à annoncer l'endpoint OpenAI
// avec une adresse correcte sur le réseau local MÊME quand l'UI est atteinte via
// le tunnel ajean.link (où location.hostname serait le domaine du relais, faux).
// trafic sortant), ou "localhost" à défaut.
//
// ⚠️ Elle ne sert plus qu'à décrire l'écoute BRUTE DU MOTEUR dans `loki network`
// (sys_network.go). L'endpoint annoncé à l'utilisateur, lui, est celui de Loki
// et se calcule tout autrement (web_public_url.go) : en conteneur, cette IP est
// celle du bridge Docker, que personne ne peut joindre.
func localIP() string {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
@@ -695,6 +703,18 @@ func handleMCP(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMCPCatalog renvoie le catalogue embarqué de serveurs MCP connus.
// Lecture seule : rien n'est installé ici, l'UI ne fait que préremplir la modale
// d'ajout avec la commande de l'entrée choisie.
func handleMCPCatalog(w http.ResponseWriter, r *http.Request) {
entries, err := MCPCatalog()
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "catalog": entries})
}
// handleMCPSave ajoute ou remplace un serveur MCP.
func handleMCPSave(w http.ResponseWriter, r *http.Request) {
var req mcpSaveReq
+57
View File
@@ -59,6 +59,63 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
}
}
// requireCompletionKey protège la surface compatible OpenAI (/v1) avec la clé
// des COMPLÉTIONS, et non avec la clé de pilotage. Trois raisons, dans l'ordre
// d'importance :
//
// 1. Sémantique : c'est exactement la séparation décrite en tête de ce fichier
// — on veut pouvoir donner à une app tierce l'accès aux complétions sans lui
// donner le droit de redémarrer la machine.
// 2. Technique : un client OpenAI n'a qu'UN en-tête Authorization. S'il y met la
// clé des complétions, une garde de pilotage le refuse ; s'il y met celle de
// pilotage, llama-server (qui reçoit l'en-tête tel quel) la refuse. Les deux
// gardes ne peuvent pas être satisfaites en même temps.
// 3. Format : les SDK OpenAI lisent body.error.message ; un {"error":"…"} plat
// leur fait afficher un message vide.
func requireCompletionKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Préflight CORS : un OPTIONS ne porte JAMAIS d'Authorization. Le refuser
// casse tout client tiers qui tourne dans un navigateur (playground web,
// interface de chat hébergée ailleurs). llama-server répond lui-même le
// préflight, ses en-têtes CORS traversent le proxy tels quels.
if r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// Trafic venu du tunnel du relais : la surface OpenAI n'y est ouverte que
// si le drapeau historique l'autorise. Sans lui, le tunnel ne servait
// cette surface que par son front TLS dédié ; la monter sur le mux ne
// doit pas l'ouvrir en douce par le canal HTTP.
if r.Header.Get(viaTunnelHeader) == "tunnel" && !oaiPublicEnabled() {
sendOAIError(w, http.StatusNotFound,
"chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found")
return
}
key, err := effectiveAPIKeyErr()
if err != nil {
// On ne sait pas si une clé protège cet endpoint : on ferme.
sendOAIError(w, http.StatusServiceUnavailable,
"configuration illisible — réessaie dans un instant", "api_error", "service_unavailable")
return
}
if key == "" {
// Aucune clé : endpoint ouvert, comme llama-server sans --api-key. Le
// panneau Accès OpenAI l'affiche en rouge, et cmdWeb le crie au
// démarrage — c'est le prix d'un réglage local sans friction.
next.ServeHTTP(w, r)
return
}
if !checkBearer(r, key) {
w.Header().Set("WWW-Authenticate", `Bearer realm="loki"`)
sendOAIError(w, http.StatusUnauthorized,
"Clé API invalide ou absente — envoie l'en-tête Authorization: Bearer <clé>.",
"invalid_request_error", "invalid_api_key")
return
}
next.ServeHTTP(w, r)
})
}
// checkBearer reports whether the request carries the expected key as an
// "Authorization: Bearer <clé>" header. La comparaison est à temps constant.
// PAS de repli ?key=<clé> en query string : une clé dans l'URL finit dans les
+253
View File
@@ -0,0 +1,253 @@
// web_engine.go — panneau « Moteur de l'image » : voir la version de llama.cpp
// qui tourne, savoir s'il en existe une plus récente, l'installer, et revenir
// en arrière si elle ne convient pas.
//
// GET /api/engine → version courante, versions installées, variante
// POST /api/engine/check → dernier build publié vs celui qui tourne
// POST /api/engine/update → job : téléchargement + test à blanc + bascule
// POST /api/engine/use → bascule vers une version déjà installée
// POST /api/engine/remove → supprime une version installée
//
// Jusqu'ici, mettre à jour llama.cpp imposait de reconstruire l'image de Loki —
// donc un rebuild complet et un redéploiement pour un composant qui publie
// plusieurs versions par jour. Le panneau ne le disait même pas : il annonçait
// « rien à mettre à jour ici ». Ces quatre routes suppriment ce détour.
package loki
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
// engineUseImage est la valeur de `tag` qui désigne le moteur d'origine, celui
// que l'image Docker fournit dans /app. C'est le retour arrière garanti : il ne
// dépend d'aucun téléchargement et ne peut pas manquer.
const engineUseImage = "image"
// handleEngineStatus décrit le moteur courant et ce qui est installé à côté.
func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
bin := currentEngineBin()
build, commit := engineBuildOf(bin)
imageBin := providedEngineBin()
source := "custom"
switch {
case imageBin != "" && samePath(bin, imageBin):
source = "image"
case engineOwns(bin):
source = "downloaded"
case prebuiltOwns(bin):
source = "prebuilt"
}
var installed []map[string]any
for _, tag := range engineInstalled() {
p := engineBinFor(tag)
installed = append(installed, map[string]any{
"tag": tag,
"bin": p,
"build": engineTagBuild(tag),
"in_use": samePath(p, bin),
})
}
sendJSON(w, 200, map[string]any{
"supported": engineOCISupported(),
"variant": engineVariant(),
"repo": ociHost() + "/" + ociEngineRepo,
"bin": bin,
"build": build,
"commit": commit,
"source": source,
"image_bin": imageBin,
"dir": engineDir(),
"installed": installed,
"job": lcJobSnapshot(0, false),
})
}
// handleEngineCheck interroge le registre : quel est le dernier build publié
// pour cette variante, et sommes-nous en retard ? Synchrone, quelques secondes.
func handleEngineCheck(w http.ResponseWriter, r *http.Request) {
if !engineOCISupported() {
sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
return
}
variant := engineVariant()
build, tag, err := engineOCILatest(variant)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
cur, _ := engineBuildOf(currentEngineBin())
sendJSON(w, 200, map[string]any{
"ok": true,
"variant": variant,
"latest": build,
"tag": tag,
"current": cur,
// Un build courant inconnu (0) ne prouve pas qu'on est à jour : on
// propose alors la mise à jour plutôt que d'affirmer le contraire.
"update": build == 0 || cur == 0 || build > cur,
})
}
// handleEngineUpdate lance le job de mise à jour. {tag} permet d'épingler une
// version précise ; sans lui, on prend la dernière publiée pour la variante.
func handleEngineUpdate(w http.ResponseWriter, r *http.Request) {
if !engineOCISupported() {
sendJSON(w, 400, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
return
}
var req struct {
Tag string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := strings.TrimSpace(req.Tag)
if err := startLcJob("engine", func() { engineRunUpdate(tag) }); err != nil {
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
func engineUnsupportedWhy() string {
return "les images officielles llama.cpp n'existent que pour Linux x86-64 et arm64 ; " +
"sur cette machine, utilise « llama.cpp précompilé » ou « llama.cpp compilé »."
}
// engineRunUpdate : corps du job. Télécharger, ESSAYER, puis seulement basculer.
// L'ordre compte — le seul vrai risque ici est un moteur plus récent que le
// runtime CUDA de l'image, et on le découvre au test à blanc, moteur courant
// encore intact.
func engineRunUpdate(tag string) {
variant := engineVariant()
ancien := currentEngineBin()
if tag == "" {
lcPhase("recherche de la dernière version publiée…")
build, latest, err := engineOCILatest(variant)
if err != nil {
lcFail(err)
return
}
tag = latest
if build > 0 {
if cur, _ := engineBuildOf(currentEngineBin()); cur == build {
lcDone(fmt.Sprintf("déjà à jour (build %d)", build))
return
}
}
}
lcAppend("variante : " + variant + " — version visée : " + tag)
bin, err := engineOCIInstall(tag, lcAppend, lcPhase)
if err != nil {
lcFail(err)
return
}
lcPhase("essai du nouveau moteur…")
if err := engineSmokeTest(bin, ancien); err != nil {
// On ne garde pas un moteur inutilisable : il encombrerait la liste et
// inviterait à le sélectionner.
_ = os.RemoveAll(filepath.Dir(bin))
lcFail(fmt.Errorf("%w\n\nLe moteur courant n'a PAS été touché. Cette version demande sans doute un runtime CUDA plus récent que celui de l'image : reconstruis l'image avec un LLAMACPP_IMAGE récent, ou épingle un build antérieur", err))
return
}
if build, commit := engineBuildOf(bin); build > 0 {
lcAppend(fmt.Sprintf("moteur testé : build %d (%s)", build, commit))
}
if err := engineSwitchTo(bin); err != nil {
lcFail(err)
return
}
// On garde la version qu'on vient de poser ; les précédentes ne servent plus
// (le retour arrière se fait sur le moteur de l'image, qui, lui, est intact).
enginePrune(map[string]bool{filepath.Base(filepath.Dir(bin)): true}, lcAppend)
lcDone("moteur mis à jour (" + tag + ")")
}
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
func engineSwitchTo(bin string) error {
wasUp := serviceIsActive()
if wasUp {
lcPhase("arrêt du moteur…")
if err := serviceAction("stop"); err != nil {
lcAppend("[warn] arrêt du moteur : " + err.Error())
}
}
if err := SetConfigKey("BIN", bin); err != nil {
if wasUp {
_ = serviceAction("start")
}
return fmt.Errorf("moteur installé mais échec d'écriture de BIN : %w", err)
}
lcAppend("BIN → " + bin)
if wasUp {
lcPhase("redémarrage du moteur…")
if err := serviceAction("start"); err != nil {
lcAppend("[warn] redémarrage du moteur : " + err.Error())
}
}
return nil
}
// handleEngineUse bascule vers une version DÉJÀ installée, ou revient au moteur
// de l'image ({tag:"image"}). Aucun réseau : c'est la sortie de secours quand une
// mise à jour s'avère décevante.
func handleEngineUse(w http.ResponseWriter, r *http.Request) {
var req struct {
Tag string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := strings.TrimSpace(req.Tag)
var bin string
if tag == engineUseImage {
if bin = providedEngineBin(); bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "cette installation n'a pas de moteur d'image (LOKI_ENGINE_BIN)"})
return
}
} else if bin = engineBinFor(tag); bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
return
}
if err := SetConfigKey("BIN", bin); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
if serviceIsActive() {
_ = serviceAction("restart")
}
sendJSON(w, 200, map[string]any{"ok": true, "bin": bin})
}
// handleEngineRemove supprime une version téléchargée. On refuse de retirer
// celle qui tourne : ça laisserait une configuration pointant dans le vide, et
// le moteur ne redémarrerait plus.
func handleEngineRemove(w http.ResponseWriter, r *http.Request) {
var req struct {
Tag string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := filepath.Base(strings.TrimSpace(req.Tag))
bin := engineBinFor(tag)
if bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
return
}
if samePath(bin, currentEngineBin()) {
sendJSON(w, 409, map[string]any{"ok": false, "error": "c'est le moteur utilisé — bascule d'abord sur une autre version"})
return
}
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
+54 -28
View File
@@ -11,7 +11,13 @@ package loki
// Le téléchargement existait déjà (handleChatFile, web_upload.go). Ce fichier
// ajoute les deux verbes qui manquaient : LISTER et SUPPRIMER.
//
// Tout est borné par workspaceRel (web_upload.go), qui résout les liens
// Le panneau s'ouvre sur le dossier de la DISCUSSION active (chat_convfiles.go)
// et n'en sort pas : les fichiers appartiennent à la discussion où ils sont nés,
// changer de discussion change ce qu'on voit. `scope=legacy` ouvre la racine du
// dossier de travail — les fichiers d'avant ce rangement, que la migration n'a
// pas su rattacher à une discussion.
//
// Tout est borné par relWithin (chat_convfiles.go), qui résout les liens
// symboliques des DEUX côtés avant de comparer : un lien posé dans le dossier
// de travail ne peut pas servir d'échappatoire vers le reste du disque.
@@ -29,7 +35,7 @@ import (
// qui aide à décider.
type wsEntry struct {
Name string `json:"name"`
Path string `json:"path"` // relatif au dossier de travail, séparateurs /
Path string `json:"path"` // relatif à la racine du panneau, séparateurs /
Dir bool `json:"dir"`
Size int64 `json:"size"`
Mod int64 `json:"mod"` // date de modification, epoch en secondes
@@ -51,17 +57,17 @@ var wsImageExt = map[string]bool{
// annoncer une taille approchée (Items le dit).
const wsWalkLimit = 20000
// wsResolve traduit un chemin RELATIF venu du client en chemin absolu vérifié.
// La chaîne vide et "." désignent la racine du dossier de travail — cas que
// workspaceRel rejette (il renvoie false sur "."), d'où ce sas.
func wsResolve(rel string) (string, bool) {
// wsResolve traduit un chemin RELATIF venu du client en chemin absolu vérifié,
// sous la racine du panneau. La chaîne vide et "." désignent cette racine — cas
// que relWithin rejette (il renvoie false sur "."), d'où ce sas.
func wsResolve(root, rel string) (string, bool) {
rel = strings.TrimSpace(rel)
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
if rel == "" || rel == "." {
return agentWorkspace(), true
return root, true
}
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(abs); !ok {
abs := filepath.Join(root, filepath.FromSlash(rel))
if _, ok := relWithin(root, abs); !ok {
return "", false
}
return abs, true
@@ -102,17 +108,21 @@ func dirSize(root string) (total int64, count int) {
return total, count
}
// handleChatFiles liste un dossier du workspace.
// handleChatFiles liste un dossier de la discussion active.
//
// GET /api/chat/files?dir=<relatif>
// → {ok, dir, parent, entries:[…], total, count}
// GET /api/chat/files?dir=<relatif>[&scope=legacy]
// → {ok, dir, parent, entries:[…], total, count, scope, legacy}
//
// `total` et `count` portent sur le dossier de travail ENTIER, pas sur le
// dossier affiché : c'est l'occupation disque de l'agent, l'information qu'on
// veut avoir sous les yeux en faisant le ménage.
// `total` et `count` portent sur le dossier ENTIER de la discussion, pas sur le
// dossier affiché : c'est ce que cette discussion occupe sur le disque, donc ce
// qu'on libère en la supprimant.
//
// `legacy` compte ce qui reste à la racine du dossier de travail, hors
// discussions : l'UI n'offre le détour « hors discussion » que si ce compte est
// non nul, et le bouton disparaît une fois le ménage fait.
func handleChatFiles(w http.ResponseWriter, r *http.Request) {
rel := r.URL.Query().Get("dir")
abs, ok := wsResolve(rel)
root, legacy := filesRoot(r.URL.Query().Get("scope"))
abs, ok := wsResolve(root, r.URL.Query().Get("dir"))
if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
@@ -128,10 +138,15 @@ func handleChatFiles(w http.ResponseWriter, r *http.Request) {
return
}
cur, _ := workspaceRel(abs) // "" à la racine
cur, _ := relWithin(root, abs) // "" à la racine
entries := []wsEntry{}
for _, e := range ents {
name := e.Name()
// Vue « hors discussion » : le dossier des discussions n'y a pas sa place,
// il est déjà accessible discussion par discussion.
if legacy && cur == "" && name == convFilesRoot {
continue
}
info, err := e.Info()
if err != nil {
continue
@@ -164,40 +179,51 @@ func handleChatFiles(w http.ResponseWriter, r *http.Request) {
parent = cur[:i]
}
}
total, count := dirSize(agentWorkspace())
total, count := dirSize(root)
scope := ""
if legacy {
scope = "legacy"
}
sendJSON(w, 200, map[string]any{
"ok": true, "dir": cur, "parent": parent, "at_root": cur == "",
"entries": entries, "total": total, "count": count,
"root": agentWorkspace(),
"root": root, "scope": scope, "legacy": legacyCount(),
})
}
// handleChatFileDelete supprime un fichier ou un dossier du workspace.
// handleChatFileDelete supprime un fichier ou un dossier de la discussion.
//
// POST /api/chat/file/delete {"path":"rapport.md"}
// POST /api/chat/file/delete {"path":"rapport.md"[,"scope":"legacy"]}
// → {ok, freed}
//
// La racine du dossier de travail est refusée : « tout supprimer » n'est pas un
// geste qu'on veut à un clic de distance, et l'agent perdrait aussi le dossier
// dans lequel il écrit.
// La racine est refusée : « tout supprimer » n'est pas un geste qu'on veut à un
// clic de distance, et l'agent perdrait aussi le dossier dans lequel il écrit.
// Le dossier des discussions l'est aussi — depuis la vue « hors discussion », il
// emporterait les fichiers de TOUTES les discussions d'un seul clic.
func handleChatFileDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Path string `json:"path"`
Scope string `json:"scope"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if strings.TrimSpace(req.Path) == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return
}
abs, ok := wsResolve(req.Path)
root, legacy := filesRoot(req.Scope)
abs, ok := wsResolve(root, req.Path)
if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
}
if abs == agentWorkspace() {
if abs == root {
sendJSON(w, 403, map[string]any{"ok": false, "error": "le dossier de travail lui-même ne se supprime pas"})
return
}
if legacy && abs == filepath.Join(agentWorkspace(), convFilesRoot) {
sendJSON(w, 403, map[string]any{"ok": false, "error": "les dossiers des discussions se suppriment discussion par discussion"})
return
}
st, err := os.Lstat(abs)
if err != nil {
sendJSON(w, 404, map[string]any{"ok": false, "error": "introuvable"})
+10 -4
View File
@@ -10,11 +10,17 @@ import (
"testing"
)
// withWorkspace pointe le dossier de travail sur un dossier temporaire. Le
// chemin est mémoïsé par un sync.Once (chat_workspace.go), donc on force la
// variable directement — c'est le seul moyen d'isoler ces tests.
// withWorkspace pointe le dossier de travail sur un dossier temporaire et rend
// le dossier de la discussion ACTIVE — celui où atterrissent les fichiers et
// que le panneau ouvre. Le chemin de la racine est mémoïsé par un sync.Once
// (chat_workspace.go), donc on force la variable directement : c'est le seul
// moyen d'isoler ces tests.
//
// testHome donne au passage une base à part : la discussion active s'y lit, et
// aucun test ne doit toucher celle de la machine qui les fait tourner.
func withWorkspace(t *testing.T) string {
t.Helper()
testHome(t)
dir := t.TempDir()
// EvalSymlinks : sur macOS /var est un lien vers /private/var, et
// workspaceRel compare des chemins résolus. Sans ça tout serait « hors du
@@ -29,7 +35,7 @@ func withWorkspace(t *testing.T) string {
prevPath := agentWorkspace()
workspacePath = dir
t.Cleanup(func() { workspacePath = prevPath })
return dir
return convWorkspace()
}
func writeFile(t *testing.T, path, body string) {
+11 -2
View File
@@ -168,6 +168,15 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) {
// Compilé », qui ne désignent rien ici.
out["provided"] = engineProvided()
out["provided_bin"] = providedEngineBin()
// Moteur téléchargé depuis l'image officielle llama.cpp (web_engine.go) : ni
// « précompilé », ni « compilé », ni « personnalisé ». Sans cette information,
// le bandeau d'état le rangeait dans « personnalisé », ce qui laisse croire à
// un bricolage alors que c'est la voie de mise à jour normale en conteneur.
out["engine"] = map[string]any{
"dir": engineDir(),
"in_use": engineOwns(cfgBin),
"tag": engineTagOf(cfgBin),
}
plan := detectBuildPlan()
out["plan"] = map[string]any{
@@ -224,8 +233,8 @@ func refuseIfProvided(w http.ResponseWriter) bool {
return false
}
sendJSON(w, 409, map[string]any{"ok": false, "error": "Le moteur est fourni par l'image Docker (" + bin +
") : il n'y a rien à installer ici, et l'image ne contient ni cmake ni compilateur. " +
"Pour changer de moteur, reconstruis l'image avec un autre build-arg LLAMACPP_IMAGE."})
") : il n'y a rien à compiler ici, et l'image ne contient ni cmake ni compilateur. " +
"Pour le mettre à jour, utilise « mettre à jour le moteur » dans Réglages → Moteur."})
return true
}
+142
View File
@@ -0,0 +1,142 @@
package loki
// web_public_url.go — l'adresse par laquelle on atteint cette instance.
//
// Le panneau « Accès OpenAI » doit afficher une adresse qu'on puisse coller
// telle quelle dans un client tiers. Il annonçait auparavant l'adresse du
// MOTEUR (http://<ip>:8080/v1), qui n'est joignable dans aucun déploiement en
// conteneur — le port n'y est pas publié — et dont l'IP, en conteneur, est
// celle du bridge Docker. Deux façons de faire mieux, dans cet ordre :
//
// 1. l'adresse publique enregistrée, quand l'utilisateur passe par un nom de
// domaine ou un reverse proxy et l'a saisie une fois pour toutes ;
// 2. sinon, l'origine de la requête elle-même — c'est-à-dire exactement le
// domaine ou l'IP qu'on a tapés pour ouvrir l'interface.
//
// Le calcul est ici, en Go, et pas dans le navigateur : une seule règle de
// priorité, réutilisable par la CLI et les journaux, et plus de construction
// d'URL à coups de concaténation côté client — c'est ce qui avait produit
// l'adresse fantôme.
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
// publicURL renvoie l'adresse publique enregistrée, normalisée, ou "".
func publicURL() string { return getStr(bkState, "public_url") }
// setPublicURL normalise puis enregistre l'adresse publique. Une chaîne vide
// efface le réglage (on repasse alors sur l'origine de la requête). Renvoie la
// valeur réellement stockée.
func setPublicURL(raw string) (string, error) {
clean, err := normalizePublicURL(raw)
if err != nil {
return "", err
}
if err := putStr(bkState, "public_url", clean); err != nil {
return "", err
}
return clean, nil
}
// normalizePublicURL ramène une saisie humaine à une origine canonique :
// "schéma://hôte[:port]", sans slash final. Pure, donc testable sans E/S.
//
// Les refus sont volontairement bavards : cette valeur est recopiée dans la
// configuration d'un autre logiciel, une erreur silencieuse s'y paierait en
// « ça ne marche pas » sans indice.
func normalizePublicURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil // effacement : ce n'est pas une erreur
}
// Sans schéma, url.Parse lit « exemple.fr:8090 » comme un schéma « exemple.fr ».
// Quelqu'un qui tape un nom de domaine nu veut https.
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("adresse illisible : %s", raw)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", fmt.Errorf("adresse en %s:// — seuls http:// et https:// sont acceptés", scheme)
}
if u.Host == "" {
return "", fmt.Errorf("adresse sans nom de machine")
}
if u.User != nil {
return "", fmt.Errorf("l'adresse ne doit pas porter d'identifiants (%s@…)", u.User.Username())
}
if u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("l'adresse ne doit porter ni paramètre ni ancre")
}
// Chemin : on tolère le « /v1 » que l'utilisateur recopie naturellement
// depuis l'adresse affichée, et rien d'autre — le reste supposerait que le
// reverse proxy réécrit les chemins, ce qu'on ne peut pas vérifier d'ici.
switch strings.TrimSuffix(u.Path, "/") {
case "", "/v1":
default:
return "", fmt.Errorf("l'adresse ne doit pas contenir de chemin (%s) : Loki sert /v1 à la racine de son port", u.Path)
}
host := u.Host
if h, port, err := net.SplitHostPort(host); err == nil {
if h == "" {
return "", fmt.Errorf("adresse sans nom de machine")
}
if n, err := net.LookupPort("tcp", port); err != nil || n == 0 {
return "", fmt.Errorf("port invalide : %s", port)
}
} else if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
// IPv6 littéral doit être entre crochets, sinon on ne sait pas où
// s'arrête l'adresse et où commence le port.
return "", fmt.Errorf("adresse IPv6 à écrire entre crochets : [%s]", host)
}
return scheme + "://" + host, nil
}
// requestBase renvoie l'origine par laquelle CETTE requête a atteint Loki :
// "schéma://hôte[:port]", sans slash final.
//
// L'hôte vient de r.Host, c'est-à-dire de l'en-tête Host du navigateur : le
// domaine réellement tapé, correct même derrière un reverse proxy qui ne
// réécrit pas Host. On n'honore PAS X-Forwarded-Host, qui élargirait la surface
// de confiance sans rien apporter ici.
//
// Le schéma, lui, a besoin de X-Forwarded-Proto : derrière un proxy qui termine
// le TLS, Loki ne voit qu'une requête en clair et annoncerait http:// pour une
// interface servie en https://. Ces en-têtes sont forgeables quand il n'y a pas
// de proxy devant — sans conséquence : le résultat n'est qu'une chaîne affichée
// à un propriétaire déjà authentifié, jamais une décision d'accès.
func requestBase(r *http.Request) string {
scheme := "http"
switch {
case r.TLS != nil:
scheme = "https"
}
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
first := strings.ToLower(strings.TrimSpace(strings.Split(fwd, ",")[0]))
if first == "http" || first == "https" {
scheme = first
}
}
host := r.Host
if host == "" {
host = "localhost"
}
return scheme + "://" + host
}
// oaiBaseURL est l'adresse à coller dans un client tiers : l'adresse publique
// enregistrée si elle existe, sinon celle par laquelle la requête est arrivée.
func oaiBaseURL(r *http.Request) string {
if u := publicURL(); u != "" {
return u
}
return requestBase(r)
}
+116
View File
@@ -0,0 +1,116 @@
package loki
// L'adresse annoncée dans le panneau « Accès OpenAI » est ce qu'on recopie dans
// la configuration d'un autre logiciel : une valeur fausse ne se voit pas, elle
// se paie en « ça ne marche pas » sans indice. D'où ces tests.
import (
"encoding/json"
"net/http/httptest"
"testing"
)
func TestNormalisePublicURL(t *testing.T) {
for _, c := range []struct {
saisie, veut string
refus bool
}{
// Un nom nu vaut https : personne ne tape le schéma.
{saisie: "ia.exemple.fr", veut: "https://ia.exemple.fr"},
{saisie: " ia.exemple.fr ", veut: "https://ia.exemple.fr"},
{saisie: "http://192.168.1.20:8090", veut: "http://192.168.1.20:8090"},
{saisie: "192.168.1.20:8090", veut: "https://192.168.1.20:8090"},
{saisie: "HTTPS://Exemple.fr/", veut: "https://Exemple.fr"},
// Le « /v1 » recopié depuis l'adresse affichée est toléré et retiré :
// c'est le geste naturel, pas une faute.
{saisie: "https://ia.exemple.fr/v1", veut: "https://ia.exemple.fr"},
{saisie: "https://ia.exemple.fr/v1/", veut: "https://ia.exemple.fr"},
{saisie: "[::1]:8090", veut: "https://[::1]:8090"},
// Vide = effacement, pas erreur.
{saisie: " ", veut: ""},
// Refus, chacun pour une raison qu'on veut pouvoir expliquer.
{saisie: "ftp://ia.exemple.fr", refus: true},
{saisie: "https://ia.exemple.fr/base", refus: true},
{saisie: "https://user:mdp@ia.exemple.fr", refus: true},
{saisie: "https://ia.exemple.fr?x=1", refus: true},
{saisie: "https://ia.exemple.fr:port", refus: true},
} {
got, err := normalizePublicURL(c.saisie)
if c.refus {
if err == nil {
t.Errorf("%q accepté → %q, refus attendu", c.saisie, got)
}
continue
}
if err != nil {
t.Errorf("%q refusé : %v", c.saisie, err)
continue
}
if got != c.veut {
t.Errorf("%q → %q, attendu %q", c.saisie, got, c.veut)
}
}
}
// L'origine de la requête : le domaine réellement tapé, et le schéma que le
// reverse proxy annonce. Sans X-Forwarded-Proto, une interface servie en https
// afficherait une adresse en http — inutilisable telle quelle.
func TestRequestBase(t *testing.T) {
for _, c := range []struct{ nom, hôte, proto, veut string }{
{"ip et port", "192.168.1.20:8090", "", "http://192.168.1.20:8090"},
{"domaine derrière un proxy TLS", "ia.exemple.fr", "https", "https://ia.exemple.fr"},
{"chaîne de proxys", "ia.exemple.fr", "https, http", "https://ia.exemple.fr"},
{"en-tête douteux ignoré", "ia.exemple.fr", "gopher", "http://ia.exemple.fr"},
} {
t.Run(c.nom, func(t *testing.T) {
r := httptest.NewRequest("GET", "http://placeholder/api/apikey", nil)
r.Host = c.hôte
if c.proto != "" {
r.Header.Set("X-Forwarded-Proto", c.proto)
}
if got := requestBase(r); got != c.veut {
t.Errorf("requestBase = %q, attendu %q", got, c.veut)
}
})
}
}
// L'adresse enregistrée l'emporte sur celle du navigateur : c'est tout l'objet
// du champ, pour le cas du reverse proxy où Loki ne voit qu'un appel interne.
func TestAdresseAffichee(t *testing.T) {
testHome(t)
lire := func() map[string]any {
r := httptest.NewRequest("GET", "http://placeholder/api/apikey", nil)
r.Host = "ia.exemple.fr"
r.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
handleAPIKey(rec, r) // GET seulement : le POST redémarre le moteur
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
}
return out
}
if got := lire()["url"]; got != "https://ia.exemple.fr/v1" {
t.Errorf("sans adresse enregistrée : url = %v", got)
}
if _, err := setPublicURL("autre.exemple.fr"); err != nil {
t.Fatal(err)
}
out := lire()
if out["url"] != "https://autre.exemple.fr/v1" {
t.Errorf("avec adresse enregistrée : url = %v", out["url"])
}
if out["public_url"] != "https://autre.exemple.fr" {
t.Errorf("public_url = %v", out["public_url"])
}
// Effacement : on repasse sur l'origine de la requête.
if _, err := setPublicURL(""); err != nil {
t.Fatal(err)
}
if got := lire()["url"]; got != "https://ia.exemple.fr/v1" {
t.Errorf("après effacement : url = %v", got)
}
}
+35 -8
View File
@@ -53,6 +53,14 @@ func cmdWeb(args []string) error {
} else {
fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]"))
}
// Même avertissement pour la surface des complétions : depuis qu'elle est
// servie ici, elle est joignable partout où l'interface l'est.
if k, _ := effectiveAPIKeyErr(); k == "" {
fmt.Printf("%s endpoint OpenAI /v1 NON protégé (aucune clé) — quiconque joint cette adresse peut utiliser le modèle :\n", yellow("[!]"))
fmt.Printf(" %s\n", bold("loki set-api-key"))
} else {
fmt.Printf("%s endpoint OpenAI : http://%s/v1 (clé requise)\n", green("[oai]"), addr)
}
// Accès distant : le tunnel vers le relais est ouvert ICI, dans le process qui
// sert déjà l'UI, et avec le MÊME mux. C'est la condition d'une conversation
@@ -73,7 +81,9 @@ func cmdWeb(args []string) error {
// ReadHeaderTimeout : sans lui, une connexion qui n'envoie jamais sa requête
// immobilise une goroutine pour toujours — et ce port écoute sur 0.0.0.0.
// Surtout PAS de WriteTimeout ici : il couperait les flux SSE du chat, qui
// restent ouverts aussi longtemps que l'utilisateur regarde la page.
// restent ouverts aussi longtemps que l'utilisateur regarde la page — et
// depuis mountOAI, il couperait aussi les complétions en streaming des
// clients tiers, en plein milieu d'une génération.
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
return srv.Serve(ln)
}
@@ -93,6 +103,9 @@ func newWebMux() *http.ServeMux {
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
// .part orphelin non reprenable : on nettoie au démarrage.
cleanStalePartFiles()
// Les fichiers des versions précédentes vivaient dans un pot commun : on les
// range dans le dossier de leur discussion (une fois par process).
migrateConvFiles()
// Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent
// en mémoire, aucun .part ne survit utilement à l'arrêt du process.
cleanStaleUploadParts()
@@ -116,10 +129,10 @@ func newWebMux() *http.ServeMux {
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(b)
})
// Polices embarquées (Bricolage Grotesque + IBM Plex Mono, sous-ensemble
// latin). Servies par Loki et non par Google Fonts : une instance locale ou
// derrière un réseau fermé doit s'afficher correctement sans appeler un tiers,
// et sans lui signaler qui consulte l'interface.
// Polices embarquées (Inter + JetBrains Mono, sous-ensemble latin). Servies
// par Loki et non par Google Fonts : une instance locale ou derrière un
// réseau fermé doit s'afficher correctement sans appeler un tiers, et sans
// lui signaler qui consulte l'interface.
mux.HandleFunc("/fonts/", func(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.URL.Path)
if !strings.HasSuffix(name, ".woff2") {
@@ -137,6 +150,12 @@ func newWebMux() *http.ServeMux {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(b)
})
// Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers
// llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de
// domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le
// moteur sur le réseau. Il n'est PAS derrière requireWebAuth : il a sa propre
// clé, celle des complétions (web_auth.go).
mountOAI(mux)
// api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go).
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
api("/api/ping", handlePing)
@@ -170,6 +189,13 @@ func newWebMux() *http.ServeMux {
api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés
api("/api/llamacpp/prebuilt/check", handleLlamacppPrebuiltCheck) // dernière release officielle vs installée
api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées
// Moteur livré par l'image : mise à jour SANS reconstruire l'image, en
// extrayant /app de l'image officielle llama.cpp (voir web_engine.go).
api("/api/engine", handleEngineStatus) // version qui tourne + versions installées
api("/api/engine/check", handleEngineCheck) // dernier build publié vs courant
api("/api/engine/update", handleEngineUpdate) // job : téléchargement + essai + bascule
api("/api/engine/use", handleEngineUse) // bascule vers une version installée (ou "image")
api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée
api("/api/presets", handlePresets)
api("/api/preset", handlePreset)
api("/api/preset/save", handlePresetSave)
@@ -178,7 +204,7 @@ func newWebMux() *http.ServeMux {
api("/api/agent/toggle", handleAgentToggle)
api("/api/agent/compact", handleCompactToggle)
api("/api/apikey", handleAPIKey)
api("/api/oai/public", handleOAIPublic)
api("/api/oai/public", handlePublicAddress) // adresse publique (domaine/IP) de cette instance
api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link)
api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → loki link
api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée
@@ -192,6 +218,7 @@ func newWebMux() *http.ServeMux {
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
api("/api/internet", handleInternet)
api("/api/mcp", handleMCP)
api("/api/mcp/catalog", handleMCPCatalog)
api("/api/mcp/save", handleMCPSave)
api("/api/mcp/delete", handleMCPDelete)
api("/api/mcp/toggle", handleMCPToggle)
@@ -220,8 +247,8 @@ func newWebMux() *http.ServeMux {
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
api("/api/chat/files", handleChatFiles) // liste un dossier du dossier de travail (panneau Fichiers)
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier du dossier de travail
api("/api/chat/files", handleChatFiles) // liste un dossier de la discussion ouverte (panneau Fichiers)
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
+37 -38
View File
@@ -1,9 +1,10 @@
// web_upload.go — dépôt de fichiers depuis le chat.
//
// L'utilisateur glisse un fichier dans le composeur ; il est écrit dans
// uploads/ à l'intérieur du workspace de l'agent (chat_workspace.go), et son
// chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut avec ses
// outils (read, bash, edit) — on ne tente ni extraction ni interprétation ici.
// uploads/ à l'intérieur du dossier de la DISCUSSION ouverte (chat_convfiles.go),
// et son chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut
// avec ses outils (read, bash, edit) — on ne tente ni extraction ni
// interprétation ici.
//
// Le transport est du JSON base64, et non du multipart : c'est la seule forme
// qui traverse le tunnel E2E d'app.ajean.link (relay_e2e.go ne dispatche que des
@@ -43,9 +44,11 @@ const (
downloadChunkMax = 8 << 20
)
// uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent.
// uploadsDir renvoie (en le créant) le dossier de dépôt de la discussion
// ouverte. Un fichier joint appartient à la discussion où il a été déposé : il
// n'apparaît pas dans les autres et part avec elle.
func uploadsDir() (string, error) {
dir := filepath.Join(agentWorkspace(), "uploads")
dir := filepath.Join(convWorkspace(), uploadsSub)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
@@ -233,22 +236,7 @@ func userMessageContent(files []attachInfo, prompt string) any {
// on lui donne un chemin absolu (voir resolveAgentPath) ; ouvrir le téléchargement
// à ces fichiers-là ferait de /api/chat/file un « lis-moi ce fichier du serveur »
// à usage général — l'API n'a pas forcément de clé et écoute sur 0.0.0.0.
func workspaceRel(abs string) (string, bool) {
root := agentWorkspace()
// EvalSymlinks des deux côtés : sans ça, un lien qui sort du dossier passerait
// le test de préfixe.
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if a, err := filepath.EvalSymlinks(abs); err == nil {
abs = a
}
rel, err := filepath.Rel(root, abs)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", false
}
return filepath.ToSlash(rel), true
}
func workspaceRel(abs string) (string, bool) { return relWithin(agentWorkspace(), abs) }
// e2eInnerHeader marque une requête dispatchée depuis le proxy chiffré
// (relay_e2e.go). Cf. handleChatFile : c'est le seul moyen pour un handler de
@@ -275,10 +263,10 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return
}
// Un chemin ABSOLU fourni par le client ne doit pas être suivi : on le traite
// comme relatif au dossier de travail, et le contrôle ci-dessous tranche.
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(abs); !ok {
// Un chemin ABSOLU fourni par le client ne doit pas être suivi : workspaceFile
// le traite comme relatif au dossier de la discussion et tranche lui-même.
abs, ok := workspaceFile(rel)
if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
}
@@ -356,8 +344,8 @@ func handleChatImage(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return
}
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(abs); !ok {
abs, ok := workspaceFile(rel)
if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
}
@@ -595,19 +583,30 @@ func handleChatUpload(w http.ResponseWriter, r *http.Request) {
// cleanStaleUploadParts efface les .part laissés par un envoi que le process n'a
// pas pu terminer (arrêt du service, crash). Appelé au démarrage : les sessions
// vivent en mémoire, aucun de ces fichiers n'est reprenable.
//
// Le balayage couvre TOUTES les discussions, pas seulement l'active : un envoi
// coupé dans une discussion qu'on ne rouvrira jamais laisserait sinon son .part
// occuper le disque pour toujours.
func cleanStaleUploadParts() {
dir, err := uploadsDir()
if err != nil {
return
dirs := []string{filepath.Join(agentWorkspace(), uploadsSub)} // dépôts d'avant le rangement par discussion
convs := filepath.Join(agentWorkspace(), convFilesRoot)
if ents, err := os.ReadDir(convs); err == nil {
for _, e := range ents {
if e.IsDir() {
dirs = append(dirs, filepath.Join(convs, e.Name(), uploadsSub))
}
}
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
n := e.Name()
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") {
os.Remove(filepath.Join(dir, n))
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
n := e.Name()
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") {
os.Remove(filepath.Join(dir, n))
}
}
}
}
+10
View File
@@ -48,6 +48,8 @@ func TestSafeUploadNameKeepsOrdinaryNames(t *testing.T) {
}
func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
withWorkspace(t)
body, _ := json.Marshal(map[string]any{
"name": "../notes.txt",
"data": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("bonjour")),
@@ -101,6 +103,8 @@ func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
// Le téléchargement est le point sensible : il lit des fichiers du serveur à
// partir d'un chemin fourni par le client.
func TestChatFileStaysInsideWorkspace(t *testing.T) {
withWorkspace(t)
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
@@ -181,6 +185,8 @@ func uploadChunk(t *testing.T, body map[string]any) (int, map[string]any) {
// près. C'est ce découpage qui permet le gigaoctet : sans lui, tout le fichier
// passait dans un seul corps JSON, gardé en mémoire des deux côtés.
func TestChatUploadChunked(t *testing.T) {
withWorkspace(t)
part1 := strings.Repeat("A", 5000)
part2 := strings.Repeat("B", 3000)
part3 := "fin"
@@ -239,6 +245,8 @@ func TestChatUploadUnknownSessionRefused(t *testing.T) {
// réponse en JSON, où du binaire ne survit pas. Le handler doit donc annoncer
// qu'on est derrière le tunnel, puis savoir servir des tranches base64.
func TestChatFileB64ForE2E(t *testing.T) {
withWorkspace(t)
dir, _ := uploadsDir()
// Des octets NON-UTF8 : c'est exactement ce que le réemballage JSON massacre.
raw := make([]byte, 5000)
@@ -308,6 +316,8 @@ func TestChatFileB64ForE2E(t *testing.T) {
// jointe). Les sessions doivent donc être indépendantes : le verrou global ne
// protège que la table, l'écriture se fait sous le verrou de la session.
func TestChatUploadConcurrentSessions(t *testing.T) {
withWorkspace(t)
const files, chunks = 4, 6
var wg sync.WaitGroup
paths := make([]string, files)