Files
Loki/internal/loki/ui/index.html
T
MichaelandClaude Fable 5 33de4a1144 VRAM : le bilan mentait, un GET coupait le moteur, la dictée bloquait
Relecture du bouton « Libérer la VRAM » (009b585) — six défauts, tous
sur la même feature :

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-29 17:53:14 +02:00

9616 lines
552 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<!-- GÉNÉRÉ depuis ui/src/ par tools/assemble-ui (go generate ./internal/loki) — NE PAS ÉDITER index.html DIRECTEMENT -->
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<!-- PWA standalone : "Ajouter à l'écran d'accueil" sur iPhone → app plein écran
SANS la barre d'onglets Safari (indispensable pour enregistrer des démos). -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Loki">
<title>Loki</title>
<!-- 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='%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
app n'en a pas (elle est en `apple-mobile-web-app-capable`) — en PWA iOS il
répond donc `browser`. `navigator.standalone` est le signal juste ici ; on
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-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>
/* ===== Polices =====================================================
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:'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 « 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.
⚠️ 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:'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:#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
restent, eux, gérés élément par élément. */
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
/* ⚠️ overflow:hidden sur html/body est INTERDIT EN PWA (tenté puis retiré le
2026-07-28) : en PWA iOS, si le document ne peut plus défiler, WebKit décale
TOUTE la mise en page à l'ouverture du clavier pour montrer le champ actif —
la carte de saisie se retrouve plaquée en haut de l'écran. Le rebond
élastique du document est le prix à payer ; il est cosmétique, le clavier
ne l'est pas.
Laisser le document défiler se paie cher, et EN PWA AUSSI : la coque y fait
956 pt (obligatoire pour que la carte atteigne le bas de l'écran) alors que la
fenêtre de mise en page n'en fait que 894 → il reste 62 pt de document
défilable en permanence, même avec un fil VIDE, et la carte de saisie — posée
en bottom:0 dans le body — part avec au lieu de rester collée en bas.
On coupe donc le défilement du document PARTOUT (2026-07-29). Le fil, lui,
défile toujours dans #chat, qui borne son rebond (overscroll:contain).
⚠️ SI LE CLAVIER iOS SE REMET À TOUT DÉCALER EN PWA, c'est ce overflow:hidden
qu'il faut rouvrir en premier — c'était le compromis d'origine. */
/* Hauteur de la coque : elle DIFFÈRE entre PWA et navigateur, il faut les deux.
- PWA (écran d'accueil) : 100vh, et SURTOUT ni 100dvh ni 100%. Mesuré sur
iPhone (2026-07-28) : la vue web couvre tout l'écran (956 pt — le contenu
passe bien sous l'horloge), mais la fenêtre de mise en page ne fait que
894 pt et reste calée en haut. Or 100dvh ET 100% valent tous deux 894 → il
restait une bande morte de 62 pt (= la zone sûre du haut) en bas de l'écran,
sous la carte de saisie. 100vh se réfère au GRAND viewport (956) : la seule
unité juste dans ce cas.
- Navigateur (barre d'URL + barre d'outils visibles) : 100vh vaut justement ce
GRAND viewport, donc la coque dépasse la zone réellement visible → le
document défile alors que le fil est vide, et le bas de la carte de saisie
(modèle / contexte) est coupé. Ici c'est 100dvh qu'il faut : la hauteur
visible du moment.
D'où : 100dvh par défaut, et 100vh UNIQUEMENT en PWA.
⚠️ Le tri se fait sur l'attribut html[data-pwa] (pose par le script du <head>),
PAS sur le media `display-mode:standalone` : WebKit adosse ce media au `display`
du MANIFESTE, or cette app n'en a pas (elle vit en apple-mobile-web-app-capable).
En PWA iOS il repond donc `browser`, la branche navigateur s'appliquait, et la
bande morte de 62 pt sous la carte de saisie etait de retour (constate le
2026-07-29).
⚠️ Ne pas y ajouter overflow:hidden : voir plus haut, ça casse le clavier iOS. */
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;overscroll-behavior:none;display:flex;font-size:14px}
html[data-pwa="1"] body{height:100vh}
/* Seul #chat défile ; le document, lui, est figé (cf. l'avertissement ci-dessus).
Exception PWA : on lui rend son défilement, sinon le clavier iOS casse tout. */
html,body{overflow:hidden}
/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne
pas passer sous l'encoche / la barre système / l'indicateur d'accueil. Les
env(safe-area-inset-*) valent 0 hors PWA, donc aucun impact ailleurs.
⚠️ PAS de padding-bas ici : le composer est posé en bottom:0 DANS le body, donc
une marge basse le décollerait du bord de l'écran et laisserait une bande de
fond visible sous la carte (visible seulement en PWA, où l'inset vaut ~34 px).
L'indicateur d'accueil est esquivé bloc par bloc, en padding INTERNE. */
body{padding:env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left)}
/* Listes déroulantes : la LISTE ouverte n'hérite pas du fond de son <select>.
Plusieurs de nos select sont en background:transparent + color clair (chevron
discret) : en thème sombre, le navigateur peignait donc du texte clair sur le
fond blanc par défaut de la liste — illisible (signalé sur « Cache KV », mais
ça touchait TOUTES les listes déroulantes en sombre). On habille donc les
<option> eux-mêmes ; color-scheme (posé sur <html>) ne suffit pas dès que le
select porte des couleurs explicites. */
select{color-scheme:inherit}
option,optgroup{background:var(--panel);color:var(--text)}
.side{width:320px;border-right:1px solid var(--border);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));overflow-y:auto;flex-shrink:0}
.main{flex:1;display:flex;flex-direction:column;min-width:0}
#menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:3px;font-size:18px;cursor:pointer}
#backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10}
/* Pastille « installation en cours » : suit exactement les règles d'affichage
du bouton ☰ (elle ne sert que quand le panneau est en tiroir, donc caché). */
#lc-chip{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(58px + env(safe-area-inset-left));z-index:20;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--panel);border:1px solid var(--accent);color:var(--accent);height:40px;padding:0 12px;border-radius:3px;font-size:12px;cursor:pointer}
#lc-chip.failed{border-color:var(--err);color:var(--err)}
@media (max-width:720px){
.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))}
.side.open{transform:translateX(0)}
#menubtn,#backdrop.open{display:block}
#lc-chip:not([hidden]){display:block}
body.drawer-open #menubtn,body.drawer-open #lc-chip{display:none}
.main{width:100%}
#chat{padding:60px 8px 12px}
.msg{max-width:92%}
#chat .msg.assistant,#chat .msg.reasoning{max-width:100%}
#inputbar{padding:8px;gap:4px}
/* 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}
/* …sauf quand elle annonce que le modèle charge : là c'est une information
d'état, pas un rappel de raccourci. */
#sendhint.waiting{display:block}
}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){
.side{width:380px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px}
}
h1{margin:0;font-size:18px;color:var(--accent)}
/* Logo : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A »
reste un bloc à part : il apparaît en glissant quand le mode agent est actif. */
#brand{display:inline-flex;align-items:center}
/* 17px : le logo pixel-art était minuscule à 10px, illisible sur un écran dense.
Multiple impair volontaire — la grille du SVG (5 px de haut) reste nette. */
#brand svg{height:17px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
#brand-word{display:inline-flex;color:var(--accent)}
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:7px;letter-spacing:0;align-self:flex-end;line-height:1;position:relative;top:1px}
.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
/* Attribution : Loki est un fork d'AJEAN (MIT). Discrète mais permanente, et le
nom du projet d'origine renvoie à son dépôt. */
.brand-fork{font-size:10px;color:var(--dim);margin:0 0 14px;line-height:1.3}
.brand-fork a{color:var(--dim);text-decoration:underline;text-underline-offset:2px}
.brand-fork a:hover{color:var(--accent)}
/* Discussions : mêmes lignes que les presets, plus deux actions au survol.
Elles restent visibles au clavier (:focus-within) pour rester atteignables
sans souris. */
.conv-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s}
.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1}
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:3px;color:var(--dim);cursor:pointer}
.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
/* Séparateur de groupe dans la barre latérale : sépare les réglages d'IA des
réglages d'application, sans imbriquer un second niveau de repli (illisible
dans une colonne aussi étroite). */
.side-sep{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);
margin:18px 0 8px;padding-top:12px;border-top:1px solid var(--border)}
/* Choix d'avatar : une grille de pastilles emoji, celle retenue encadrée. */
.avpick{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.avpick button{font-size:15px;line-height:1;padding:5px 6px;background:var(--panel);
border:1px solid var(--border);border-radius:4px;cursor:pointer}
.avpick button:hover{border-color:var(--accent)}
.avpick button.sel{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 15%,transparent)}
/* Avatar + nom en tête de bulle : l'emoji garde sa taille propre, le nom hérite
du style de .label (petites capitales) mais sans forcer la casse du prénom. */
.msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px}
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
/* Captures d'écran et images de l'agent : bornées à la largeur de la bulle, et
cliquables pour ouvrir en grand dans un onglet. */
.msg .body img{max-width:100%;height:auto;border-radius:4px;border:1px solid var(--border);display:block;margin:8px 0}
/* pastille d'état service, en haut à droite de la barre latérale */
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
.statuspill.err{color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)}
.statuspill.loading{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)}
.statuspill.loading .dot{animation:pulse 1s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.statuspill .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
/* en-tête + bouton d'ajout de skill (toujours visible) */
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
/* Même gabarit que les autres boutons des panneaux (angle 3 px, 12 px) : la
pilule arrondie détonnait au milieu de boutons rectangulaires. */
.addbtn{font-size:12px;padding:6px 10px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
(le trait est réservé aux sections principales — cf. `details`). La séparation
visuelle vient de la carte de lignes qui suit (.slist). */
/* Barre « Voir la mémoire » : libellé à gauche, bouton « + ajouter » à droite.
Pas de chevron : la liste s'ouvre sous la ligne, ça se voit. */
.pages-bar{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;padding:7px 0;margin-top:4px}
.pages-bar .pages-title{font-size:11.5px;color:var(--dim);font-weight:600}
.pages-bar .addbtn{margin-left:auto}
.mem-search{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:5px 9px;font:inherit;font-size:12px;margin:0 0 6px}
.mem-search:focus{outline:none;border-color:var(--accent)}
.mem-list{max-height:240px;overflow-y:auto;overscroll-behavior:contain}
.mem-more{font-size:11px;color:var(--dim);text-align:center;padding:6px 0 2px}
.ask-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:60;display:flex;align-items:center;justify-content:center;padding:20px}
.ask-box{background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:420px;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.18)}
.ask-title{font-size:15px;font-weight:600;color:var(--text);margin-bottom:8px}
.ask-msg{font-size:13px;color:var(--dim);line-height:1.5;white-space:pre-wrap;word-break:break-word}
.ask-input{width:100%;box-sizing:border-box;margin-top:12px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:9px 11px;font:inherit;font-size:13px}
.ask-input:focus{outline:none;border-color:var(--accent)}
.ask-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
.ask-actions button{margin:0;padding:7px 16px}
.ask-ok{border-color:var(--accent);color:var(--accent)}
.ask-ok.danger{border-color:var(--err);color:var(--err)}
#brand-a{display:inline-flex;line-height:0;color:var(--text);margin-right:2px}
/* Mode agent : le A passe en couleur d'accent (il était masqué hors agent,
la marque se lisant alors « Loki »). L'état agent reste signalé par sa
pastille dans les réglages. */
#brand.agent #brand-a{color:var(--accent)}
/* interrupteur (switch) */
/* Sous-groupe du mode agent (Mémoire, Accès internet, Serveurs MCP, Paramètres).
MÊME grammaire que « Machine » ou « Apparence » : étiquette de groupe NUE
au-dessus, puis UNE carte .slist qui porte toutes les lignes. Auparavant
chaque sous-groupe était une carte qui embarquait son propre titre et du
contenu libre aux marges variables — quatre îlots décollés au lieu d'une
colonne régulière. Le grisé « agent désactivé » se pose sur le GROUPE
(étiquette comprise), plus sur la carte seule. */
.gated-group{margin:0 0 10px;transition:opacity .15s}
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
.switch input{position:absolute;opacity:0;width:0;height:0}
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
détache pas du rail en mode clair. Même teinte que .pe-sl. */
.switch .slider{position:absolute;inset:0;background:color-mix(in srgb,var(--dim) 55%,transparent);border-radius:999px;transition:background .25s ease}
.switch .slider::before{content:"";position:absolute;width:14px;height:14px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .25s ease;box-shadow:0 1px 2px rgba(0,0,0,.25)}
.switch input:checked + .slider{background:var(--accent)}
.switch input:checked + .slider::before{transform:translateX(16px);background:var(--bg)}
.switch input:focus-visible + .slider{box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--accent)}
h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600}
details>summary::-webkit-details-marker{display:none}
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
details[open]>summary>.preset-add{display:inline-block}
/* Les panneaux de réglages ne sont plus des <details>/<summary> mais des
<section class="set-pane"> : sans cette règle, le « + » des Presets restait
en display:none et il n'y avait plus aucun moyen d'en créer un. */
.set-pane-h>.preset-add{display:inline-block}
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
.tag.ok{color:var(--ok);border-color:var(--ok)}
.tag.err{color:var(--err);border-color:var(--err)}
button{background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:6px 10px;font:inherit;cursor:pointer;margin:2px}
button:hover{border-color:var(--accent);color:var(--accent)}
button:disabled{opacity:.5;cursor:not-allowed}
.row{display:flex;gap:4px;flex-wrap:wrap}
.preset{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--border);border-radius:3px;margin:4px 0;cursor:pointer;background:var(--panel)}
.preset:hover{border-color:var(--accent)}
.preset.active{border-color:var(--accent);color:var(--accent);border-width:1.5px;padding:5.5px 9.5px}
.preset>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.preset>button{flex-shrink:0}
/* Liste de lignes cliquables (presets, pages mémoire) posée à fleur de carte :
une pile de petites cartes bordées faisait « trop de carrés » là où le reste
du menu aligne des lignes séparées par un filet. L'actif se signale par un
voile et un liseré à gauche, plus par un cadre propre. */
.rowlist>.preset{border:0;border-radius:0;margin:0;padding:6px 10px;background:transparent}
.rowlist>.preset:not(:last-child){border-bottom:1px solid var(--border)}
.rowlist>.preset:hover{background:var(--row-bg)}
.rowlist>.preset.active{position:relative;background:var(--row-bg-on);color:var(--text);font-weight:600}
/* La carte (.slist) laisse volontairement déborder son contenu (menus), elle ne
rogne donc pas ses coins : c'est la LIGNE de bord qui porte l'arrondi, sinon
le voile dépasse l'angle de la carte. */
.rowlist:first-child>.preset:first-child{border-radius:4px 4px 0 0}
.rowlist:last-child>.preset:last-child{border-radius:0 0 4px 4px}
.rowlist:first-child:last-child>.preset:only-child{border-radius:4px}
/* Liseré de gauche : un trait DROIT (pas un arc) qui court sur TOUTE la hauteur
de la ligne. Donc pas de box-shadow interne (l'arrondi le courberait) mais un
élément à part, aux bords francs, que l'arrondi de la ligne de bord se
contente de ROGNER — il se perd dans l'angle au lieu de le suivre. */
.rowlist>.preset{overflow:hidden}
.rowlist>.preset.active::before,.rowlist>.preset.pending::before{content:"";position:absolute;
left:0;top:0;bottom:0;width:3px;background:var(--text)}
/* La liste est REDESSINÉE à chaque rafraîchissement : une transition ne jouerait
pas (l'élément vient de naître), d'où des @keyframes d'entrée. Le liseré entre
en glissant depuis la gauche, le voile et la puce arrivent en fondu.
⚠️ Elles ne sont posées QUE via .sel-anim, classe mise par loadPresets le seul
redessin où la sélection a bougé : sans ça elles repartaient à chaque
rafraîchissement et la puce clignotait en continu. */
@keyframes bar-in{from{transform:translateX(-100%)}to{transform:translateX(0)}}
@keyframes row-in{from{background:transparent}}
@keyframes dot-in{from{opacity:0;transform:scale(.3)}to{opacity:1;transform:scale(1)}}
.sel-anim.active::before,.sel-anim.pending::before{animation:bar-in .26s cubic-bezier(.2,.7,.3,1) both}
.rowlist>.preset.sel-anim.active,.rowlist>.preset.sel-anim.pending{animation:row-in .26s ease-out both}
.sel-anim .preset-dot{animation:dot-in .26s cubic-bezier(.2,1.5,.5,1) both}
/* Bascule en cours : la ligne visée s'allume DÉJÀ (le clic doit se voir tout de
suite), mais sa barre reste EN RETRAIT — même blanc, opacité plus basse, et
elle respire. Le service met plusieurs secondes à redémarrer : la barre pleine
(plus la puce) est réservée au preset vraiment actif. */
.rowlist>.preset.pending{position:relative;background:var(--row-bg);color:var(--text);font-weight:600}
.rowlist>.preset.pending::before{animation:bar-breathe 1.5s ease-in-out infinite}
.rowlist>.preset.sel-anim.pending::before{
animation:bar-in .26s cubic-bezier(.2,.7,.3,1) both,bar-breathe 1.5s ease-in-out .26s infinite}
@keyframes bar-breathe{0%,100%{opacity:.5}50%{opacity:.2}}
/* Ligne à deux étages (titre + étiquettes) : l'interligne interne suffit, on
resserre donc le haut et le bas. */
.rowlist>.preset .preset-meta{margin-top:4px}
/* Messages et « voir plus » de la liste : alignés sur le retrait des lignes. */
.rowlist>.muted,.rowlist>.mem-more{padding:9px 10px}
.preset-info{flex:1;min-width:0}
.preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word}
/* Puce du preset actif : disque dessiné en CSS (le caractère « ● » dépend de la
police du système — plus petit et plus bas sous Windows que sous macOS). */
.preset-dot{display:inline-block;width:7px;height:7px;border-radius:50%;
background:currentColor;margin:0 7px 0 0;flex-shrink:0;
/* ⚠️ PAS `vertical-align:middle` : ce mot-clé se cale sur la HAUTEUR D'X de la
police, qui n'est pas la même d'un système à l'autre (Segoe UI sous Windows
vs SF sous macOS) — la puce tombait trop bas sur PC. Un décalage EN LONGUEUR
part de la ligne de base et ne dépend que de la taille de texte : même
rendu partout. .34em ≈ milieu des capitales, moins la moitié du disque. */
vertical-align:calc(.34em - 3.5px)}
/* Icône d'engrenage : centrée verticalement sur la ligne (sa silhouette ronde se
lit bien au milieu de son coin, là où le crayon penché tirait de travers). */
.preset-edit{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;
margin:0 -2px 0 0;padding:4px;line-height:0;background:transparent;border:none;opacity:.55}
.preset-edit:hover{opacity:1;color:var(--accent);background:transparent}
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */
.help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle}
.help:hover{color:var(--accent);border-color:var(--accent)}
/* La bulle est ancrée à la ligne (.switchrow position:relative), pas au « ? »,
pour rester alignée au bord gauche du panneau et ne pas déborder. */
.help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:3px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;font-family:var(--font);cursor:auto}
.help:hover>.tip,.help:focus>.tip,.help.open>.tip{display:block}
.kv{display:flex;justify-content:space-between;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);font-size:13px;min-height:38px;align-items:center}
.kv span:first-child{color:var(--text);flex-shrink:0}
.kv:last-child{border-bottom:0}
/* direction:rtl tronque par la GAUCHE (on garde la fin d'un chemin long), mais
inversait l'ordre des groupes : « 18,2 / 24 Go » s'affichait « Go 24 / 18,2 ».
unicode-bidi:plaintext rend chaque valeur dans son sens naturel tout en
gardant l'ancrage à droite. */
.kv span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;unicode-bidi:plaintext;text-align:right;min-width:0;color:var(--dim)}
.bar{height:5px;background:var(--border);border-radius:3px;overflow:hidden;margin:0}
.bar>div{height:100%;background:var(--accent)}
#chat{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:20px;display:flex;flex-direction:column;gap:14px}
:root{--col:max(20px,calc(50% - 450px))}
@media (max-width:720px){#chat{padding:60px 8px 12px}}
.msg{max-width:80%;padding:10px 14px;border-radius:4px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
.msg.assistant,.msg.reasoning{white-space:normal}
.msg .body{overflow-wrap:anywhere;word-break:break-word}
.msg.user{align-self:flex-end;background:var(--bubble);color:var(--text)}
.msg.user .label{color:var(--dim)}
.msg.assistant{align-self:flex-start;background:var(--panel);border:1px solid var(--border)}
.msg.assistant.errmsg{border-color:rgba(248,81,73,.55);background:rgba(248,81,73,.08);color:var(--err)}
.msg .body>*{margin:1em 0}
.msg .body>*:first-child{margin-top:0}
.msg .body>*:last-child{margin-bottom:0}
.msg .body h1,.msg .body h2,.msg .body h3,.msg .body h4{font-weight:600;line-height:1.25;margin:1.2em 0 .5em}
.msg .body h1{font-size:1.3em} .msg .body h2{font-size:1.15em} .msg .body h3{font-size:1em} .msg .body h4{font-size:.95em}
.msg .body ul,.msg .body ol{padding-left:1.4em}
.msg .body li{margin:.15em 0}
.msg .body li>p{margin:0}
.msg.assistant .body code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere;word-break:break-word}
.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;transition:opacity .15s,color .15s}
.msg.assistant .body pre:hover .copybtn,.msg.reasoning .body pre:hover .copybtn{opacity:1}
.copybtn:hover{border-color:var(--accent);color:var(--accent)}
.copybtn.done{color:var(--ok);border-color:var(--ok);opacity:1}
.msg.assistant .body pre code,.msg.reasoning .body pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4}
.msg.assistant .body blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)}
.msg.assistant .body a{color:var(--accent);text-decoration:none}
.msg.assistant .body a:hover{text-decoration:underline}
.msg.assistant .body table{border-collapse:collapse;margin:.5em 0}
.msg.assistant .body th,.msg.assistant .body td{border:1px solid var(--border);padding:4px 8px}
.msg.assistant .body hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
#scrollbtn{position:absolute;bottom:80px;right:20px;background:var(--panel);border:1px solid var(--border);color:var(--accent);width:36px;height:36px;border-radius:50%;cursor:pointer;display:none;font-size:16px;z-index:5}
#scrollbtn.show{display:block}
.msg.reasoning{align-self:flex-start;background:transparent;border:1px dashed var(--border);color:var(--mag);font-size:12px;max-width:90%;overflow-wrap:anywhere;word-break:break-word}
.msg.tool{align-self:flex-start;background:transparent;border:1px dashed var(--border);max-width:90%;white-space:normal;padding:8px 12px;font-size:12px;overflow-wrap:anywhere;word-break:break-word}
.msg.tool .tool-head{font-size:12px;color:var(--accent);font-weight:600;margin-bottom:6px}
/* ===== Diff des écritures (outil edit / mémoire) ==================
Lignes ajoutées en vert, retirées en rouge, contexte neutre. Les couleurs
viennent de variables : chaque thème peut les ajuster (clair vs sombre). */
.msg.tool pre.diff{display:block;padding:0;overflow-x:auto;background:var(--code-bg)}
.msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)}
.msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)}
.msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)}
/* Écriture en direct : le bloc se remplit ligne à ligne pendant que le modèle
tape. Seule la dernière ligne est en fondu (.fresh) — animer tout le bloc à
chaque nouvelle ligne le ferait clignoter. Hauteur bornée + scroll auto en
bas (voir 08-chat-render.js) pour qu'un long script ne pousse pas le fil. */
.msg.tool pre.diff.live .dl.fresh{animation:difffresh .18s ease-out}
@keyframes difffresh{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.msg.tool pre.diff.live .dl.fresh{animation:none}}
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
.diff-count .a{color:var(--add)}
.diff-count .d{color:var(--del)}
.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
.msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
@keyframes toolblink{0%,100%{opacity:.45}50%{opacity:1}}
/* Indicateur « le modèle réfléchit » : 3 points animés avant le 1er token. */
.msg.typing{display:inline-flex;gap:5px;align-items:center;padding:14px 16px}
.msg.typing span{width:7px;height:7px;border-radius:50%;background:var(--dim);animation:typingdot 1.2s infinite ease-in-out}
.msg.typing span:nth-child(2){animation-delay:.16s}
.msg.typing span:nth-child(3){animation-delay:.32s}
@keyframes typingdot{0%,70%,100%{opacity:.25;transform:translateY(0)}35%{opacity:1;transform:translateY(-4px)}}
/* Compactage du contexte : le MÊME indicateur, juste étiqueté. Avant, une
mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux
éléments pour un seul état d'attente. Ici les points passent en accent et un
libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */
.msg.typing.compacting span:not(.tlabel):not(.wclock){background:var(--accent)}
/* Compteur de travail sur l'indicateur « … » : il annule la mise en forme de
pastille que `.msg.typing span` applique à TOUT span de la bulle. */
.msg.typing .wclock{width:auto;height:auto;border-radius:0;background:none;animation:none;
font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--dim);
margin-left:7px;white-space:nowrap}
.msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap}
@keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}}
/* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur
discret, à sa place chronologique — un toast qui disparaît ne dit pas OÙ la
coupure a eu lieu. */
.compact-mark{display:flex;align-items:center;gap:10px;margin:14px 2px;font-size:11px;color:var(--dim);letter-spacing:.04em;animation:cmarkin .35s ease}
.compact-mark::before,.compact-mark::after{content:"";flex:1;height:1px;background:var(--border)}
@keyframes cmarkin{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
/* --- Message en attente d'accusé serveur ---------------------------------
Affiché DÈS l'appui sur envoyer, en gris et légèrement effacé : le message
ne disparaît plus le temps de l'aller-retour. La classe est retirée quand le
flux renvoie l'événement `user`, et la bulle reprend sa couleur pleine —
signal net que c'est bien parti. */
.msg.user.pending{opacity:.45;transition:opacity .2s ease}
.msg.user.pending .label{font-style:italic}
/* --- Voile de chargement du fil ------------------------------------------
Couvre la zone du chat tant que le replay n'est pas fini. Sans lui, une
connexion lente donne un chat vide indiscernable d'une conversation vide. */
#chat-loading{position:absolute;inset:0;z-index:6;display:none;flex-direction:column;
align-items:center;justify-content:center;gap:12px;background:var(--bg);
color:var(--dim);font-size:12px;letter-spacing:.04em;pointer-events:none}
#chat-loading.show{display:flex}
/* Accueil du fil vide. Posé en surimpression du chat (et non DANS le fil) pour ne
pas perturber le défilement ni le rendu du replay ; pointer-events:none pour
qu'il ne vole jamais un clic à la zone de saisie. Il vit sous le voile de
chargement (z-index plus bas) : jamais les deux visibles en même temps. */
#chat-empty{position:absolute;inset:0 0 150px;z-index:3;display:none;flex-direction:column;
align-items:center;justify-content:center;gap:10px;pointer-events:none;
opacity:0;transition:opacity .35s ease}
#chat-empty.show{display:flex;opacity:1}
/* Le clone perd les règles d'id de #brand (les id sont retirés), dont l'espace
entre le « A » et le mot : on le repose ici, à l'échelle du logo agrandi
(2 px pour 10 px de haut → 4 px pour 20 px), sinon le A s'encastre dans le J. */
#ce-logo{display:inline-flex;align-items:center;gap:4px;color:var(--text)}
#ce-logo svg{height:20px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
#ce-logo .ce-word{display:inline-flex;color:var(--accent)}
#ce-title{font-size:14px;color:var(--dim);letter-spacing:.03em}
.cl-dots{display:inline-flex;gap:5px;align-items:center}
.cl-dots span{width:7px;height:7px;border-radius:50%;background:var(--dim);animation:typingdot 1.2s infinite ease-in-out}
.cl-dots span:nth-child(2){animation-delay:.16s}
.cl-dots span:nth-child(3){animation-delay:.32s}
.msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block}
/* Bulles repliables (reasoning / tool) : une fois la réponse arrivée, on les
replie en douceur pour désencombrer le fil, tout en gardant le label (et son
compteur de tokens) cliquable pour redéplier. */
.msg.collapsible .label{cursor:pointer;user-select:none;margin-bottom:0}
/* ===== Barre latérale ==============================================
Colonne de réglages dense : 12 sections repliées doivent tenir sans faire
défiler. Un seul gabarit, repris des listes de la modale de preset.
Hauteurs : en-tête de section 34 px, ligne de réglage 34 px. */
.side{padding:10px 12px}
/* ⚠️ Le raccourci `padding` ci-dessus écrase les esquives d'encoche posées plus
haut (règles tiroir). Quand la barre est un TIROIR (téléphone, ou mode
« barre en tiroir » sur ordinateur), elle est en position:fixed : le padding
du <body> ne la protège pas, il faut donc reposer les env() ici — sinon
« Loki » et la pastille d'état passent sous l'encoche en PWA iOS. */
@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))}
}
.sidehead{margin-bottom:10px}
/* --- En-tête de section --- */
details{margin:0;border-top:1px solid var(--border)}
details:first-of-type{border-top:0}
details[open]{padding-bottom:10px}
details>summary{display:flex;align-items:center;gap:8px;height:34px;font-size:13px;font-weight:500;
color:var(--text);cursor:pointer;list-style:none;user-select:none}
.stitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Tous les titres du menu (sections ET étiquettes de groupe) s'écrivent de la
MÊME façon : monospace, capitales, même interlettrage. La casse vient du CSS,
pas du HTML, pour qu'aucun libellé ne puisse diverger. Seule la taille et la
couleur distinguent les deux niveaux. */
.stitle,.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{
font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em}
/* Pastille d'état d'une section (mode agent, accès internet, MCP, accès distant).
Un point + un mot, dans une pilule — l'ancien couple « on / off » en étiquette
carrée se lisait mal et jurait avec le reste du menu. Actif = pilule pleine
(inversée), inactif = contour discret. */
.sbadge{flex:none;display:inline-flex;align-items:center;gap:5px;
font-family:var(--font);font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;
padding:2px 8px;border-radius:999px;border:1px solid var(--border);color:var(--dim);background:transparent;white-space:nowrap}
.sbadge::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;opacity:.55}
.sbadge.on{background:var(--text);border-color:var(--text);color:var(--bg)}
.sbadge.on::before{opacity:1}
.sbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}
.sbadge.err{color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent)}
.sbadge:empty{display:none}
/* Dans une étiquette de groupe, la pastille se plaque à DROITE (comme dans les
titres de section, où .stitle occupe la place) au lieu de coller au libellé.
L'ordre visuel reste : libellé · « ? » · … · pastille. */
.subhead .sbadge{margin-left:auto;order:2}
.subhead .help{order:1}
details>summary::-webkit-details-marker{display:none}
details>summary:hover{color:var(--dim)}
/* Chevron : un carré dont on n'affiche que deux côtés. Il pivote AUTOUR DE SON
CENTRE (aucune translation), sinon il se décale en tournant. */
details>summary::after{content:"";flex:none;width:6px;height:6px;
border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);
transform:rotate(-45deg);transition:transform .18s ease}
details[open]>summary::after{transform:rotate(45deg)}
/* --- Étiquette de groupe : une seule règle pour toutes les variantes --- */
.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{position:relative;display:flex;align-items:center;
font-size:11px;font-weight:600;color:var(--dim);margin:10px 2px 5px}
.io-cap{margin:0 2px 5px}
/* ⚠️ APRÈS la règle commune ci-dessus, qui pose une marge haute/basse : dans une
LIGNE de carte, elle décalait le libellé vers le bas au lieu de le centrer. */
.pages-bar .pages-title{margin:0}
/* Étiquette de groupe en tête d'un sous-groupe du mode agent : elle suit une
carte, elle a donc besoin de son air normal — mais pas d'un cumul de marges. */
.gated-group>.subhead:first-child{margin-top:12px}
/* Une étiquette de groupe qui suit immédiatement le titre de section n'a pas
besoin de reprendre de l'air : le titre en a déjà donné. */
details>summary+.subhead{margin-top:4px}
.io-group{margin-top:8px}
/* --- Carte de liste --- */
/* overflow VISIBLE : les infobulles (« ? ») sont positionnées en absolu par
rapport à la ligne, un débordement caché les TRONQUERAIT — elles doivent
pouvoir déborder de la carte. Les lignes n'ont pas de fond propre, l'arrondi
n'a donc rien à rogner. */
.slist{background:var(--panel);border:1px solid var(--border);border-radius:4px;
overflow:visible;margin:0 0 8px}
.slist:last-child{margin-bottom:0}
/* Contenu libre (champ + boutons) : le rembourrage passe sur la carte. */
.slist.pad{padding:8px 10px}
.slist.pad>:first-child{margin-top:0}
.slist.pad>:last-child{margin-bottom:0}
/* Contenu libre posé DANS une carte de lignes (champs Crawl4AI, liste MCP,
pages mémoire) : même rembourrage et même filet de séparation qu'une ligne,
pour qu'il ne flotte pas au milieu de la carte. */
.slist>.rowbody{padding:9px 10px}
.slist>.rowbody:not(:first-child),.slist>.pages-bar:not(:first-child){border-top:1px solid var(--border)}
/* …sauf juste après une ligne de réglage : elle porte DÉJÀ son filet bas, les
deux se cumuleraient en un trait double. */
.slist>.switchrow+.rowbody,.slist>.switchrow+.pages-bar{border-top:0}
.slist>.rowbody>:first-child{margin-top:0}
.slist>.rowbody>:last-child{margin-bottom:0}
.slist>.pages-bar{min-height:34px;padding:4px 10px;margin-top:0}
/* Contrôle posé à droite d'une ligne (liste « mode » de la mémoire) : même place
que l'interrupteur, largeur ajustée au contenu. */
.switchrow>.sctl{margin-left:auto;order:2;width:auto;max-width:62%}
/* --- Ligne de réglage : hauteur constante, contrôle à droite --- */
.switchrow{position:relative;display:flex;align-items:center;gap:10px;min-height:34px;padding:6px 0;
font-size:12.5px;cursor:pointer;user-select:none}
.slist>.switchrow{padding:6px 10px}
.slist>.switchrow:not(:last-child){border-bottom:1px solid var(--border)}
.switchrow .switch{margin-left:auto;order:2;flex:none}
.switchrow>span:first-child{min-width:0}
/* --- Bloc de statistique (GPU, RAM) --- */
.stat{padding:9px 10px;display:flex;flex-direction:column;gap:6px}
.stat:not(:last-child){border-bottom:1px solid var(--border)}
/* La RAM vit dans son propre conteneur (masqué quand l'info manque) : le filet
de séparation se pose donc sur le conteneur, pas entre deux .stat frères. */
#ram-details{border-top:1px solid var(--border)}
.stat-h{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
.stat-n{font-size:12.5px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.stat-v{flex:none;font-size:12.5px;color:var(--text);font-variant-numeric:tabular-nums}
.stat-s{font-size:11px;color:var(--dim)}
/* --- Lignes clé / valeur (config active) --- */
.kv{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:34px;
padding:6px 10px;border-bottom:1px solid var(--border);font-size:12.5px}
.kv:last-child{border-bottom:0}
.kv span:first-child{color:var(--text);flex-shrink:0}
/* --- Champs et boutons --- */
.sctl,.io-input{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);
border-radius:3px;padding:7px 9px;font:inherit;font-size:12.5px}
.sctl:focus,.io-input:focus{outline:none;border-color:var(--dim)}
textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.side button:not(.preset-edit):not(.addbtn):not(.preset-add):not(.copybtn):not(.iconbtn){font-size:12px;padding:6px 10px;border-radius:3px}
.side .row{display:flex;gap:6px;align-items:center;margin:6px 0}
.side .row .sctl,.side .row .io-input{flex:1;min-width:0}
/* Bouton d'action à côté d'un champ (copier, ouvrir) : un CARRÉ à la hauteur du
champ, icône au trait centrée. Le glyphe seul (⧉, ↗) dans un bouton texte se
posait de travers et changeait de largeur d'une police à l'autre. */
.iconbtn{flex:none;display:inline-flex;align-items:center;justify-content:center;
width:34px;height:34px;padding:0;margin:0;color:var(--dim);background:var(--panel);
border:1px solid var(--border);border-radius:4px;cursor:pointer}
.iconbtn:hover{color:var(--text);border-color:var(--dim);background:var(--accent-bg)}
.iconbtn svg{width:15px;height:15px;display:block}
.side .btn-danger-o{border-color:var(--err);color:var(--err)}
.side .btn-danger-o:hover{background:var(--danger-bg);border-color:var(--err);color:var(--err)}
.side .muted{font-size:11.5px;line-height:1.5}
/* ===== Réglages d'affichage ======================================
Chaque option est un interrupteur indépendant (Apparence), porté par un
attribut sur <html> et persisté dans localStorage + /api/prefs :
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)
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}
/* 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
APRÈS le fold (width 0s .3s) à la fermeture pour rétrécir la bulle au label. */
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
/* Raisonnement / outils : hauteur BORNÉE avec défilement interne. Un long
raisonnement (des milliers de tokens) faisait grandir la page de plusieurs
écrans ; la carte reste maintenant à taille fixe et suit la génération en
défilant toute seule (voir flushRender, 09-stream.js). L'animation de repli
garde son overflow:hidden via la hauteur inline — les deux cohabitent. */
.msg.reasoning .bodywrap,.msg.tool .bodywrap{max-height:280px;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin}
/* Une capture d'écran ne se lit pas par le trou d'une serrure : la carte qui en
porte une échappe au plafond (l'image a déjà son propre max-height à 420 px,
cliquable plein écran via la visionneuse). */
.msg.tool .bodywrap:has(.tool-shot){max-height:none}
.msg.collapsible .bodywrap>.body{min-width:0}
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
ligne de progression pleine largeur affleurant le bord) + zone de saisie. */
#composer{border-top:1px solid var(--border);background:var(--panel)}
/* La jauge est un ENFANT de #ctxmeta (et non plus un frère au-dessus) : ainsi
elle peut être clippée par la forme arrondie du bloc — indispensable au
thème ChatGPT où elle sert de bord bas de carte. Position/rendu inchangés
pour les autres thèmes : filet collé en haut de la ligne de contexte. */
#ctxtrack{position:absolute;top:0;left:0;right:0;height:3px;background:var(--panel);overflow:hidden}
#ctx-fill{height:100%;width:0%;background:var(--ok,#3a7);transition:width .3s,background .3s}
#ctxmeta{position:relative;display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:8px 12px 0}
#ctx-compact{display:none;margin:0;padding:2px 10px;font-size:10px;background:var(--warn-bg);border-color:var(--warn,#c93)}
#inputbar{padding:8px 12px;display:flex;gap:8px}
/* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir
changer ses marges, ce qu'un style en ligne empêchait). */
#sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7}
/* Moteur pas encore prêt : l'aide clavier cède la place à l'état du chargement. */
#sendhint.waiting{opacity:1;color:var(--dim)}
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
#input:focus{outline:none;border-color:var(--accent)}
.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:4px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)}
#toast.show{opacity:1}
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
/* iOS Safari auto-zoome au focus sur tout champ < 16px. On force 16px sur les
appareils tactiles uniquement (le desktop garde son rendu, le pinch-zoom
manuel reste possible). */
@media (hover:none) and (pointer:coarse){
#input,#sysprompt,#modal input,#modal textarea,#modal select{font-size:16px}
}
/* Appareil TACTILE en PAYSAGE : on réduit nettement tout pour gagner de la place
(interface « un peu grosse » en horizontal, utile pour enregistrer des démos).
Détection par (hover:none)+(pointer:coarse)+landscape : infaillible sur tout
téléphone couché, sans dépendre d'un seuil de largeur/hauteur en pixels (les
iPhone Pro Max font 932px de large et ~430px de haut, ce qui piégeait les
seuils précédents). Le desktop (souris) n'est jamais touché.
NB : la règle iOS 16px anti-zoom plus haut est surchargée ici volontairement
pour #input — on garde une saisie compacte (le pinch-zoom manuel reste dispo). */
@media (hover:none) and (pointer:coarse) and (orientation:landscape){
body{font-size:11.5px}
/* Tiroir latéral : on décale le TIROIR ENTIER après l'encoche (left = inset)
au lieu de padder à l'intérieur — sinon grosse bordure vide à gauche qui
rétrécit le contenu. Padding interne normal → largeur utile pleine. */
.side{width:300px;max-width:300px;left:env(safe-area-inset-left);padding:calc(14px + env(safe-area-inset-top)) 14px calc(14px + env(safe-area-inset-bottom)) 14px}
#chat{padding:48px 10px 6px;gap:7px}
.msg{padding:6px 10px;border-radius:4px;max-width:78%}
#chat .msg.assistant,#chat .msg.reasoning{max-width:88%}
#inputbar{padding:5px 8px;gap:6px}
/* 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}
#composer .muted{display:none}
#ctxmeta{padding:3px 10px 0}
h1{font-size:15px}
h2,details>summary{font-size:13px}
#menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))}
#lc-chip{height:32px;font-size:11px;left:calc(46px + env(safe-area-inset-left));top:calc(6px + env(safe-area-inset-top))}
}
/* --- Backend llama.cpp (2 modes : rapide / optimisée) --------------------- */
.lc-head{font-size:12px;line-height:1.5}
/* Les trois modes sont des LIGNES de la carte d'installation (elles étaient des
cartes dans une carte) : pas de cadre propre, un filet entre elles, comme
n'importe quelle ligne de réglage du menu. */
.lc-modes{display:flex;flex-direction:column}
.lc-modes.busy{opacity:.5;pointer-events:none}
.slist>.lc-modes:not(:first-child){border-top:1px solid var(--border)}
.lc-mode{padding:10px;cursor:pointer;transition:background .12s}
.lc-mode:not(:last-child){border-bottom:1px solid var(--border)}
.lc-mode:hover{background:var(--accent-bg)}
.lc-mode-t{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px}
/* « conseillé » : pilule PLEINE (texte inversé). En contour, elle héritait du
gris atténué du thème monochrome et devenait illisible en mode sombre. */
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:4px;padding:1px 6px}
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
/* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle
les trois cartes grandissaient d'un cran une fois l'état connu. */
.lc-mode-state{font-size:11px;min-height:16px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.lc-mode-go{color:var(--accent)}
.lc-mode-active-tag{color:var(--accent);font-weight:600}
.lc-update-link{color:var(--dim);text-decoration:none;cursor:pointer}
.lc-update-link:hover{color:var(--accent)}
/* Choix du moteur dans l'éditeur de modèle (3 options : rapide/optimisé/perso) */
.be-opts{display:flex;flex-direction:column;gap:7px;margin:6px 0 8px}
.be-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer}
.be-opt input{margin:0;flex-shrink:0}
.be-note{font-size:11px;color:var(--dim);margin-left:auto}
/* ===== Éditeur de preset — listes groupées façon Réglages iOS ======== */
#modal .modal-card{max-width:520px;overflow:hidden}
.pe-body{padding:18px 16px 24px;display:flex;flex-direction:column;gap:26px;overflow-y:auto;overflow-x:hidden;flex:1;background:var(--bg)}
/* Nombres : pas de flèches natives (spinner), champ épuré. */
.pe-val[type=number]{-moz-appearance:textfield}
.pe-val::-webkit-outer-spin-button,.pe-val::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
/* Un groupe = un petit titre + une liste encadrée aux coins arrondis. */
.pe-group{display:flex;flex-direction:column;gap:8px}
/* `display:flex` sur la classe l'emporte sur la règle par défaut du navigateur
pour [hidden] : sans cette ligne, masquer un groupe via l'attribut hidden
n'avait AUCUN effet visible (section « Cartes graphiques » qui restait
affichée avec une seule carte). */
.pe-group[hidden]{display:none}
.pe-gh{font-size:12px;font-weight:600;color:var(--dim);padding:0 6px}
.pe-list{background:var(--panel);border:1px solid var(--border);border-radius:3px;overflow:hidden}
/* Ligne : libellé à gauche, contrôle à droite, séparateur fin entre lignes. */
.pe-row{display:flex;align-items:center;gap:14px;min-height:48px;padding:9px 15px}
/* Séparateur porté par la ligne SUIVANTE (border-top), pas par la précédente
(border-bottom + :not(:last-child)) : `:last-child` compte aussi les lignes
masquées, donc une ligne repliée gardait sa bordure basse, qui doublait
celle de la carte. Avec border-top, la bordure est portée par l'élément
caché lui-même — invisible tant qu'il l'est. */
.pe-row + .pe-row{border-top:1px solid var(--border)}
.pe-row-l{font-size:14px;color:var(--text);flex:1;min-width:0;line-height:1.3}
.pe-row-l .pe-sub{display:block;font-size:11px;color:var(--dim);margin-top:2px}
.pe-row-c{flex:none;display:flex;align-items:center;gap:6px;max-width:62%;justify-content:flex-end}
/* Valeur éditable sans bordure, alignée à droite (nombre / texte). */
.pe-val{border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;padding:4px 0;width:110px;max-width:44vw}
.pe-val::placeholder{color:var(--dim)}
.pe-val:focus{outline:none}
.pe-unit{font-size:12px;color:var(--dim)}
/* Select sans bordure, avec chevron discret. */
.pe-selc{position:relative;display:flex;align-items:center;max-width:100%}
.pe-selc select{appearance:none;-webkit-appearance:none;border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;text-align-last:right;padding:4px 16px 4px 0;cursor:pointer;max-width:100%;text-overflow:ellipsis}
.pe-selc select:focus{outline:none}
.pe-selc::after{content:"›";position:absolute;right:0;transform:rotate(90deg);color:var(--dim);font-size:14px;pointer-events:none}
/* Variante pleine largeur : la liste native s'ouvre alignée à gauche de la
ligne (et non collée au bord droit) et le nom de fichier a la place de
s'afficher en entier. */
.pe-selc.wide{width:100%}
.pe-selc.wide select{flex:1;text-align:left;text-align-last:left;padding-right:18px}
.pe-selc.wide::after{right:2px}
/* Ligne « champ pleine largeur » (Nom, chemin, config brute…). */
.pe-row.stack{flex-direction:column;align-items:stretch;gap:8px}
/* Répartition entre cartes graphiques : UNE barre, sur laquelle on glisse
directement. Le <input type=range> est posé par-dessus, piste transparente :
sinon on voyait deux barres superposées (la barre de proportion + la piste
blanche native du navigateur). Deux teintes du MÊME accent, pas deux
couleurs — la barre exprime une proportion, pas deux catégories. */
.gpu-split{position:relative;display:flex;align-items:center;height:22px}
.gpu-bar{display:flex;width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)}
.gpu-bar>span{background:var(--accent);flex:none;min-width:0;transition:width .12s}
.gpu-bar>span.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)}
.gpu-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize}
.gpu-range:focus{outline:none}
.gpu-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none}
.gpu-range::-moz-range-track{height:22px;background:transparent;border:none}
.gpu-range::-moz-range-progress{background:transparent}
.gpu-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize}
.gpu-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize}
.gpu-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.gpu-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.gpu-legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:11.5px;color:var(--dim)}
.gpu-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:5px}
.gpu-legend i.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)}
.gpu-shares{display:flex;flex-wrap:wrap;gap:10px}
.gpu-share{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--dim)}
.gpu-share input{width:62px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:5px 7px;font:inherit;text-align:right}
.gpu-share input:focus{outline:none;border-color:var(--accent)}
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s}
.pe-inp:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
.pe-row.stack.plain .pe-inp{border:none;background:transparent;padding:6px 0;font-size:15px}
.pe-row.stack.plain .pe-inp:focus{box-shadow:none}
/* Champ + bouton accolé (téléchargement) — même trait que les autres champs,
sans pastille colorée qui ressort. */
.pe-inpbtn{display:flex;align-items:stretch}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0;border-right:0}
/* Le bouton reprend EXACTEMENT le cadre du champ (mêmes coins, même bordure) :
sans ça, un thème qui arrondit tous les boutons (ChatGPT) le détachait du
champ, avec un coin rond au milieu de la paire. */
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-radius:0 4px 4px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s,background .12s}
.pe-inpbtn button:hover:not(:disabled){color:var(--bg);background:var(--accent);border-color:var(--accent)}
.pe-inpbtn:focus-within .pe-inp,.pe-inpbtn:focus-within button{border-color:var(--accent)}
.pe-inpbtn button:disabled{opacity:.5;cursor:default}
/* Contrôle segmenté (moteur) façon iOS. */
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:4px;padding:3px;width:100%}
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:3px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
.pe-seg>label:has(input:checked){background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.22)}
.pe-seg input{display:none}
.pe-seg .be-note{display:none}
.pe-seg-note{font-size:11px;color:var(--dim);padding:0 2px;min-height:14px}
.pe-note{font-size:11px;color:var(--dim);line-height:1.45}
/* Barre de progression (téléchargement de modèle). Sans total connu, elle
défile en boucle via .indet. */
.pe-bar{height:4px;border-radius:999px;background:color-mix(in srgb,var(--dim) 26%,transparent);overflow:hidden}
.pe-bar>i{display:block;height:100%;width:0;border-radius:999px;background:var(--accent);transition:width .35s linear}
.pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none}
.pe-bar.done>i{width:100%}
@keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}}
/* Action discrète en texte (annuler un téléchargement…). */
.pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer}
.pe-link:hover{color:var(--text)}
.pe-link:disabled{opacity:.5;cursor:default;text-decoration:none}
/* Interrupteur à glissière. */
.pe-switch{position:relative;flex:none;width:40px;height:24px}
.pe-switch input{position:absolute;opacity:0;width:0;height:0}
.pe-sl{position:absolute;inset:0;background:color-mix(in srgb,var(--dim) 55%,transparent);border-radius:999px;transition:background .18s}
.pe-sl::before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .18s;box-shadow:0 1px 3px rgba(0,0,0,.25)}
.pe-switch input:checked+.pe-sl{background:var(--accent)}
.pe-switch input:checked+.pe-sl::before{transform:translateX(16px);background:var(--bg)}
.pe-switch input:focus-visible+.pe-sl{box-shadow:0 0 0 3px var(--accent-bg)}
/* Repli « avancé » / config brute : une ligne cliquable dans un groupe. */
.pe-adv{background:var(--panel);border:1px solid var(--border);border-radius:3px;overflow:hidden}
.pe-adv>summary{cursor:pointer;font-size:14px;color:var(--text);padding:13px 15px;list-style:none;display:flex;align-items:center;user-select:none}
.pe-adv>summary::-webkit-details-marker{display:none}
.pe-adv>summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:translateY(-2px) rotate(45deg);transition:transform .15s}
.pe-adv[open]>summary::after{transform:translateY(1px) rotate(-135deg)}
.pe-adv[open]>summary{border-bottom:1px solid var(--border)}
.pe-adv .pe-adv-body{padding:14px 15px;display:flex;flex-direction:column;gap:10px}
.pe-adv textarea.pe-inp{min-height:210px;resize:vertical;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden}
/* Ligne « éditer le .env » : chevron de disclosure + textarea de config brute. */
.pe-caret{display:inline-block;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:rotate(-45deg);transition:transform .15s}
.pe-caret.open{transform:rotate(45deg)}
#m-content{min-height:220px;resize:vertical;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden}
/* Nombres avancés : chaque paire label/valeur sur sa propre ligne (réutilise pe-row). */
.pe-adv .pe-list{margin:0}
/* Variante « champ en clair » : page mémoire → le contenu est le champ principal. */
.pe-adv.pe-raw-plain{border:none;background:transparent}
.pe-adv.pe-raw-plain>summary{display:none}
.pe-adv.pe-raw-plain .pe-adv-body{padding:0}
/* Pied de modale. */
.pe-foot{padding:9px 12px;border-top:1px solid var(--border);display:flex;justify-content:space-between;
align-items:center;gap:8px;flex-wrap:nowrap}
.pe-foot .foot-l{display:flex;align-items:center;gap:8px;min-width:0}
.pe-foot .foot-r{display:flex;gap:8px;flex:none}
.pe-btn{margin:0;padding:7px 15px;font-size:12.5px;border-radius:4px;font-weight:600;cursor:pointer;
border:1px solid var(--border);background:var(--panel);color:var(--text);white-space:nowrap}
.pe-btn:hover{border-color:var(--dim)}
.pe-btn.primary{border-color:var(--accent);background:var(--accent);color:var(--bg)}
.pe-btn.primary:hover{filter:brightness(1.08)}
/* Suppression : icône seule, en retrait. Un bouton plein « supprimer » pesait
autant que « enregistrer » alors que c'est l'action rare — et à trois boutons
larges, le pied passait à deux lignes sur téléphone. */
.pe-del{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;
margin:0;padding:0;border:1px solid transparent;border-radius:4px;background:transparent;color:var(--err);cursor:pointer}
.pe-del svg{width:15px;height:15px;display:block}
.pe-del:hover{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 40%,transparent)}
/* Option d'une confirmation : une PASTILLE à cocher, pas une case système suivie
d'un bout de texte. Éteinte : contour discret. Allumée : elle passe au rouge,
comme l'action destructrice qu'elle étend. Vit dans la boîte de confirmation
(#ask-check), plus dans le pied de l'éditeur. */
.pe-delmodel{align-self:flex-start;margin-top:14px;display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;
letter-spacing:.02em;color:var(--dim);cursor:pointer;white-space:nowrap;
padding:5px 11px;border:1px solid var(--border);border-radius:999px;background:var(--panel);
transition:color .12s,border-color .12s,background .12s}
.pe-delmodel input{position:absolute;opacity:0;width:0;height:0}
/* Le point tient lieu de case à cocher : creux au repos, plein une fois coché. */
.pe-delmodel::before{content:"";width:6px;height:6px;border-radius:50%;flex:none;
border:1px solid currentColor;background:transparent}
.pe-delmodel:hover{color:var(--text);border-color:var(--dim)}
.pe-delmodel:has(input:checked){color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent);background:var(--danger-bg)}
.pe-delmodel:has(input:checked)::before{background:currentColor}
/* Éditeur de preset/mémoire — mise en forme propre du modale */
.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px}
/* Ouverture/fermeture animées. L'état par défaut est l'état FERMÉ ; la classe
.show (posée par showModal/hideModal) fait jouer la transition dans les deux
sens — une @keyframes n'aurait animé que l'ouverture. */
.modal-ov,.ask-overlay{opacity:0;transition:opacity .16s ease}
.modal-ov.show,.ask-overlay.show{opacity:1}
.modal-card,.ask-box{transform:translateY(10px) scale(.985);opacity:0;
transition:transform .18s cubic-bezier(.2,.8,.3,1),opacity .18s ease}
.modal-ov.show .modal-card,.ask-overlay.show .ask-box{transform:none;opacity:1}
@media (prefers-reduced-motion:reduce){
.modal-ov,.ask-overlay,.modal-card,.ask-box{transition:none}
}
.modal-card{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:3px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)}
.modal-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.modal-head strong{font-size:14px}
/* Croix de fermeture : un CARRÉ, pas un rectangle. Avec un padding asymétrique
(3px vertical / 10px horizontal) le glyphe paraissait écrasé et la cible de
clic était basse. 32px, glyphe centré, taille de cible correcte au doigt. */
.modal-x{margin:0;padding:0;width:32px;height:32px;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:19px;line-height:1;color:var(--dim)}
.modal-body{padding:16px;overflow:auto;flex:1}
/* Modale ouverte mais pas encore remplie : on affiche le voile de chargement À LA
PLACE du formulaire et du pied. Estomper le formulaire ne suffisait pas — on
voyait quand même les champs s'écrire un par un derrière le voile. */
/* Voile OPAQUE, posé sous l'en-tête (le titre reste lisible) : `visibility:hidden`
sur le formulaire suffirait en théorie, mais un fond plein garantit qu'aucun
reste du preset précédent (moteur coché, réglages) ne puisse apparaître une
fraction de seconde. --mh = hauteur mesurée de l'en-tête, posée à l'ouverture. */
/* ⚠️ Aucune transition tant que le formulaire est caché, NI pendant la frame où
il réapparaît : WebKit diffère les transitions des éléments non rendus et les
joue à leur retour à l'écran. Le sélecteur de moteur glissait donc de l'ancien
preset au nouveau SOUS LES YEUX, une fois le chargement terminé. */
.modal-ov.loading .pe-body *,.modal-ov.no-anim .pe-body *{transition:none!important}
.modal-load{display:none;position:absolute;inset:var(--mh,52px) 0 0 0;z-index:2;
align-items:center;justify-content:center;background:var(--bg);border-radius:0 0 5px 5px}
.modal-ov.loading .modal-load{display:flex}
/* visibility (et non display) : le formulaire garde sa place, donc la carte a
déjà sa taille finale — la révéler ne provoque plus d'à-coup de hauteur. */
.modal-ov.loading .modal-body,
.modal-ov.loading .pe-body,
.modal-ov.loading .pe-foot,
.modal-ov.loading .modal-foot{visibility:hidden}
/* Rond qui tourne — les trois points sont la signature du CHAT, pas des modales. */
.spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--border);
border-top-color:var(--dim);animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.spinner{animation-duration:2s}}
.modal-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
.foot-l{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.foot-r{display:flex;gap:8px}
.mform{display:flex;flex-direction:column;gap:14px}
.fld{display:flex;flex-direction:column;gap:5px}
.fld-l{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)}
.fld-l .opt{text-transform:none;letter-spacing:0;font-weight:400;opacity:.65}
.fld-hint{font-size:11px;color:var(--dim);margin-top:2px}
.ctl{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:9px 11px;font:inherit}
.ctl:focus{outline:none;border-color:var(--accent)}
.ta{min-height:220px;resize:vertical;line-height:1.5}
.card{border:1px solid var(--border);border-radius:4px;padding:13px;display:flex;flex-direction:column;gap:13px}
.card-h{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)}
.row{display:flex;gap:6px}
.row .ctl{flex:1;min-width:0}
.btn-dl{flex-shrink:0;margin:0}
.input-btn{display:flex}
.input-btn .ctl{flex:1;min-width:0;border-top-right-radius:0;border-bottom-right-radius:0}
.input-btn .btn-in{flex-shrink:0;margin:0;padding:9px 13px;border-left:0;border-top-left-radius:0;border-bottom-left-radius:0}
.input-btn .btn-in:hover{border-left:0}
.seg{display:flex;gap:6px}
.seg>label{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;min-height:46px;padding:6px 4px;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;background:var(--panel);transition:border-color .12s,background .12s,color .12s}
.seg>label:hover{border-color:var(--accent)}
.seg>label:has(input:checked){border-color:var(--accent);background:var(--accent-bg);color:var(--accent)}
.seg input{display:none}
.seg .be-note{font-size:10px;color:var(--dim);margin:0}
.btn-danger{background:var(--danger-bg);border-color:var(--err);color:var(--err);margin:0}
.btn-save{border-color:var(--accent);color:var(--accent);margin:0}
.del-model{align-items:center;gap:5px;font-size:11px;color:var(--dim)}
.del-model input{margin:0}
.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:12px;color:var(--dim);font-weight:600;padding:6px 0;margin-top:2px}
.lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block}
.lc-logbar.open #lc-log-caret{transform:rotate(90deg)}
#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:3px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
.lc-spin{display:inline-block;animation:spin 1s linear infinite}
/* Serveurs MCP */
.mcp-list{display:flex;flex-direction:column;gap:4px}
.mcp-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--border);border-radius:3px;background:var(--panel);cursor:pointer}
.mcp-row:hover{border-color:var(--accent)}
.mcp-row.off{opacity:.6}
.mcp-dot{flex:none;width:8px;height:8px;border-radius:50%}
.mcp-dot-ok{background:var(--accent)}
.mcp-dot-err{background:var(--err)}
.mcp-dot-off{background:var(--dim)}
.mcp-info{flex:1;min-width:0}
.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:4px;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 */
/* Sélecteur d'intensité du raisonnement, dans la barre de saisie. Discret par
défaut (même poids visuel que les icônes voisines), il ne s'affirme qu'au
survol ou quand une intensité est imposée. */
#think-effort{flex:none;align-self:center;height:22px;padding:0 4px;font-size:11px;
color:var(--dim);background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}
#think-effort:hover:not(:disabled){color:var(--text);border-color:var(--border)}
#think-effort:disabled{opacity:.4;cursor:not-allowed}
.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:4px;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:3px;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:3px;padding:6px}
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:3px;cursor:pointer}
.mcp-tool-row:hover{background:var(--panel)}
.mcp-tool-switch{flex:none;transform:scale(.85)}
.mcp-tool-name{font-size:12px;color:var(--text);word-break:break-all}
/* Accès OpenAI / Accès distant : champs lecture seule (URL, clé) + libellés
compacts, pour une présentation homogène et sans surcharge visuelle. */
.io-input{flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px 10px;font:inherit;font-size:13px}
/* Modale MCP (ajout/édition) : les champs des groupes stdio/http n'avaient aucun
espacement vertical (le conteneur n'est pas un flex à gap) → ils se touchaient.
On aligne leur rythme sur celui du reste du formulaire (.mform). */
#mcp-stdio-fields,#mcp-http-fields{display:flex;flex-direction:column;gap:14px}
/* ===== Mise en page de la conversation =============================
Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond),
question de l'utilisateur en pastille arrondie à droite, composer en
« pilule », pas d'étiquettes VOUS/Loki. Ces règles surchargent la mise en
page générique plus haut : elles doivent donc rester APRÈS elle. */
body{font-size:15px}
h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
/* Barre latérale : plus sombre/atténuée que la zone de conversation. */
.side{background:var(--panel);border-right-color:transparent}
@media (max-width:720px){.side{background:var(--panel)}}
/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile.
⚠️ JAMAIS le raccourci `padding` ici : le rembourrage BAS réserve la hauteur
de la carte de saisie (voir #chat plus bas) et il est réglé ailleurs. Une
règle en raccourci le remet silencieusement à sa valeur du raccourci — c'est
exactement ce qui, jusqu'à ce correctif, laissait la fin du fil coincée sous
la carte au-delà de 721 px de large. Longhands uniquement. */
#chat{padding-top:28px;padding-inline:max(16px,calc(50% - 380px));gap:22px;scrollbar-gutter:stable both-edges}
/* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */
#chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7}
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:3px;padding:11px 18px;max-width:75%;line-height:1.6}
#chat .msg.user .label{color:var(--dim)}
/* Libellés : l'amont les masquait tous ici, les mots « user » / « assistant »
n'ajoutant rien à l'alignement. Ils portent désormais un avatar et un prénom
(voir 17-identity.js), qui eux identifient vraiment l'interlocuteur — on les
réaffiche donc, en ligne et discrets. Restent masqués les libellés SANS
avatar, c'est-à-dire les bulles techniques non repliables. */
#chat .msg .label{display:none}
#chat .msg.collapsible .label{display:block}
#chat .msg.user .label,#chat .msg.assistant .label{display:flex;align-items:center;gap:0;opacity:.85}
/* Côté question, le libellé s'aligne à droite comme la bulle. */
#chat .msg.user .label{justify-content:flex-end}
/* Mesures de génération sous la réponse (prefill / decode). Assez discrètes pour
ne pas concurrencer le texte, assez lisibles pour être utiles. Escamotables
depuis Apparence → Affichage. */
#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em;
color:var(--dim);opacity:.75}
#chat .msg .statline:empty{display:none}
/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de
travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la
réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en
entier — sans .has-work il ne resterait qu'un blanc. */
html[data-hide-stats="1"] #chat .msg .statline>.spd{display:none}
html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
/* Arrivée en fondu montant plutôt qu'un surgissement sec. L'opacité finale (.75)
est celle de la règle ci-dessus : l'animation doit y ATTERRIR, sinon la ligne
clignote en repassant à sa valeur de repos. */
#chat .msg .statline-in{animation:statin .28s cubic-bezier(.2,.7,.3,1) both}
@keyframes statin{from{opacity:0;transform:translateY(3px)}to{opacity:.75;transform:none}}
@media (prefers-reduced-motion:reduce){#chat .msg .statline-in{animation:none}}
/* Raisonnement / outils : encart gris discret plutôt qu'un cadre pointillé. */
#chat .msg.reasoning,#chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:3px;color:var(--dim);padding:12px 16px}
#chat .msg.reasoning{color:var(--dim)}
#chat .msg.tool .tool-head{color:var(--text)}
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{border-radius:3px;padding:12px 14px}
#chat .msg.assistant .body a{text-decoration:underline;text-underline-offset:2px}
/* Composer = UNE seule carte posée PAR-DESSUS le fil : la conversation
continue de défiler DERRIÈRE elle et s'efface en fondu (dégradé vers le fond)
au lieu de s'arrêter net au-dessus. Dans la carte, de haut en bas : la saisie
et ses boutons, un pied discret (préréglage · contexte · compacter), puis la
jauge de contexte qui FORME le bord bas — elle épouse les coins arrondis
(overflow:hidden sur le rayon). Les trois blocs sont des frères dans le HTML :
on les recolle en dessinant les bordures par morceaux et l'ordre visuel vient
de l'ordre flex — aucun changement de markup. */
/* Le padding BAS dégage l'indicateur d'accueil de l'iPhone en PWA : la carte
flotte au-dessus de lui, son arrondi bas reste donc bien visible. Hors PWA
l'inset vaut 0 → strictement les 8 px d'origine. */
/* --fade = la bande transparente au-dessus de la carte, celle où le fil doit
s'effacer. Le dégradé s'exprime dans la MÊME unité (et non en % de la hauteur
totale du composeur, qui varie) : l'opacité est donc pleine exactement au bord
haut de la carte. Avec un %, le fondu finissait SOUS ce bord et le texte
restait lisible au ras de la carte — l'impression de « passer derrière ». */
#composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;--fade:34px;padding:var(--fade) 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))}
/* Réserve sous le fil = hauteur RÉELLE du composeur, mesurée et publiée par
syncComposer() (08-chat-render.js). Une constante redevient fausse dès que la
saisie grandit ou qu'une pièce jointe s'affiche, et la fin du fil repasse hors
d'atteinte. 170px n'est que le repli du tout premier rendu, avant mesure.
Pas de env(safe-area-inset-bottom) ici : il est déjà DANS le padding bas du
composeur, donc dans sa hauteur mesurée — le compter deux fois creusait un
trou en PWA iOS. */
#chat{padding-bottom:calc(var(--composer-h,170px) + 12px)}
#scrollbtn{bottom:calc(var(--composer-h,170px) + 10px);border-radius:999px}
#inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:4px 4px 0 0}
#input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
/* 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}
/* 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
découper la jauge dans sa propre forme — la barre est bien épaisse ET rognée
exactement sur la courbe, symétrique du haut. */
#ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;overflow:hidden}
/* ⚠️ NE PAS piloter la position du composer en JS quand le clavier s'ouvre
(tenté puis retiré le 2026-07-28, deux fois) : ni asservir la hauteur de la
coque à visualViewport.height, ni décaler le composer de la hauteur du
clavier. Dans les deux cas la carte part en haut de l'écran en PWA iOS.
iOS gère déjà correctement le clavier tout seul — on le laisse faire. */
#ctx-text,#status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em;white-space:nowrap}
#ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)}
#ctx-compact:hover{border-color:var(--dim);color:var(--text)}
/* Jauge = bord bas de la carte : épaisse, plaquée au ras du bord, rognée par
l'arrondi du pied de carte. */
#ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
#ctx-fill{background:var(--text);opacity:.75}
#sendhint{order:4;padding:12px var(--col) 2px}
/* ===== Pièces jointes ==============================================
Un fichier joint n'est jamais lu par la page : il est déposé dans le dossier
de travail de l'IA et seul son nom revient. L'affichage se limite donc à une
pastille — pas d'aperçu, pas de vignette, rien qui promette plus que ça.
Le trombone vit dans le PIED de la carte, à droite avec le décompte de contexte
et « compacter ». Il est à LEUR échelle (une ligne d'outils de 10 px), pas à
celle de « send » : joindre un fichier est un geste occasionnel. */
#attach{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
display:flex;align-items:center;justify-content:center;border-radius:999px;
background:transparent;border:1px solid transparent;color:var(--dim)}
#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#attach:active{transform:scale(.9)}
#attach svg{display:block}
/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte.
Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de
dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant
le rembourrage. La carte ne bouge donc pas — c'est la bande vide qui se
remplit, au lieu du composeur qui grandit vers le haut.
Elles ne vont PAS dans le pied : cette ligne appartient au nom du modèle. */
#attach-list{order:0;display:none;gap:6px;margin:0 var(--col);padding:0 2px 8px;
overflow-x:auto;scrollbar-width:none}
#attach-list::-webkit-scrollbar{display:none}
#attach-list.show{display:flex}
#composer:has(#attach-list.show){--fade:6px}
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
cadre, sinon le texte de la conversation transparaît derrière. */
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
box-shadow:0 2px 10px rgba(0,0,0,.10)}
/* Pastille de fichier. Le × n'apparaît qu'au survol : au repos la pastille doit
rester lisible d'un coup d'œil (nom + poids), pas encombrée d'un bouton. */
.chip-file{display:flex;align-items:center;gap:8px;max-width:230px;min-width:0;
padding:4px 12px;border-radius:999px;background:var(--row-bg-on);
border:1px solid transparent;font-size:12px;line-height:1.3}
/* Avec un × à droite, la pastille se ferme SUR lui : le rembourrage de droite
revient à l'épaisseur du rond, sinon le bouton flotte loin du bord et la
pastille paraît de travers. */
.chip-file:has(button){padding-right:4px}
.chip-file .cf-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chip-file .cf-size{flex:none;color:var(--dim);font-size:11px}
/* Le × est TOUJOURS visible : le révéler au survol le rendait inatteignable sur
mobile, où il n'y a pas de survol — donc impossible de retirer un fichier. */
.chip-file button{flex:none;margin:0;padding:0;width:20px;height:20px;font-size:14px;
display:flex;align-items:center;justify-content:center;line-height:1;
border-radius:50%;border:none;background:transparent;color:var(--dim)}
.chip-file button:hover{background:var(--bubble);color:var(--text)}
/* Transfert en cours : un liseré qui tourne, plutôt qu'une pastille grisée dont
on ne sait pas si elle travaille ou si elle a échoué. */
.chip-file.up .cf-size::after{content:"";display:inline-block;vertical-align:-2px;
margin-left:7px;width:10px;height:10px;border-radius:50%;
border:1.5px solid var(--border);border-top-color:var(--text);animation:spin .7s linear infinite}
.chip-file.err{background:var(--danger-bg);color:var(--err)}
.chip-file.err .cf-size{color:var(--err)}
@media (prefers-reduced-motion:reduce){.chip-file.up .cf-size::after{animation:none}}
/* Fichiers d'un message envoyé : leur propre rangée AU-DESSUS de la bulle,
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
une seule bulle difforme qui déborde vers le haut. */
/* align-items:flex-start : depuis que la rangée peut contenir une VIGNETTE, une
pastille texte voisine s'étirait à sa hauteur (étirement par défaut du flex) —
et, arrondie à 999px, se retrouvait en gros ovale. Chaque objet garde sa
hauteur propre. */
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
align-items:flex-start;gap:6px;max-width:75%}
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
/* Vignette dans une pastille (composeur) : assez grande pour reconnaître la
capture qu'on vient de coller, assez petite pour que la rangée reste une
rangée de pastilles et pas une planche-contact. */
.chip-file .cf-thumb{flex:none;width:26px;height:26px;object-fit:cover;border-radius:3px;
background:var(--panel)}
.chip-file.has-thumb{padding-left:4px}
/* Pièce jointe IMAGE d'un message envoyé : la vignette remplace la pastille.
Plafonnée pour ne pas déséquilibrer le fil ; cliquable pour la voir en grand. */
#chat .msg-files .msg-img{display:flex;max-width:100%;border-radius:4px;overflow:hidden;
border:1px solid var(--border);background:var(--panel)}
/* margin/border remis à zéro : .chatimg (posée par hydrateImages) s'applique
aussi ici, or le cadre est déjà porté par la tuile. */
#chat .msg-files .msg-img img{display:block;max-width:200px;max-height:160px;
width:auto;height:auto;object-fit:contain;cursor:zoom-in;margin:0;border:none;border-radius:0}
/* Images posées par l'IA dans sa réponse (captures, /api/chat/image). Sans ce
plafond elles sortaient à leur taille NATIVE : une capture d'écran remplissait
la colonne entière et poussait le texte hors de vue. */
#chat .chatimg{display:block;max-width:100%;max-height:280px;width:auto;height:auto;
margin:6px 0;border:1px solid var(--border);border-radius:4px;cursor:zoom-in}
/* Loupe : l'image reprend toute la fenêtre, moins une marge. Le fond ferme au
clic (curseur zoom-out), l'image non — on veut pouvoir la regarder de près. */
#lightbox{position:fixed;inset:0;z-index:80;display:none;align-items:center;
justify-content:center;padding:28px;background:rgba(0,0,0,.72);cursor:zoom-out;
opacity:0;transition:opacity .16s ease}
#lightbox.show{opacity:1}
#lightbox img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;
border-radius:4px;box-shadow:0 16px 50px rgba(0,0,0,.45);cursor:default}
#lightbox .lb-x{position:absolute;top:10px;right:14px;margin:0;padding:0;
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
font-size:22px;line-height:1;border:none;border-radius:50%;
background:rgba(0,0,0,.35);color:#fff}
#lightbox .lb-x:hover{background:rgba(0,0,0,.6)}
#lightbox .lb-name{position:absolute;left:0;right:0;bottom:10px;text-align:center;
color:#fff;opacity:.72;font-size:12px;pointer-events:none}
@media (prefers-reduced-motion:reduce){#lightbox{transition:none}}
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
#chat .msg .filelink{cursor:pointer}
#chat .msg .filelink::after{content:"↓";margin-left:5px;font-size:.9em;opacity:.7;text-decoration:none;display:inline-block}
#chat .msg .filelink.busy{opacity:.55;pointer-events:none}
/* Voile de dépôt. Il couvre la zone de conversation, curseur inclus : c'est la
réponse à « est-ce que je peux lâcher ici ? », elle doit être sans ambiguïté. */
#dropveil{position:absolute;inset:0;z-index:20;display:none;align-items:center;
justify-content:center;padding:24px;background:rgba(0,0,0,.28);pointer-events:none}
#dropveil.show{display:flex}
#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:3px;
background:var(--panel);color:var(--text);font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.18)}
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
button,.mem-search{border-radius:4px}
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
.pe-inpbtn .pe-inp{border-radius:4px 0 0 4px}
.pe-inpbtn button{border-radius:0 4px 4px 0}
.input-btn .ctl{border-radius:4px 0 0 4px}
.input-btn .btn-in{border-radius:0 4px 4px 0}
.preset{border-radius:3px}
/* Les presets restent des cartes (fond + bordure) : à plat, sans fond ni
cadre, la liste ne se lisait plus comme des lignes distinctes. L'actif se
signale par sa bordure — un simple voile d'accent (7 % de blanc sur fond
noir) était invisible en mode sombre. */
.preset{background:var(--panel);border-color:var(--border)}
.preset:hover{background:var(--accent-bg);border-color:var(--dim)}
.preset.active{background:var(--panel);border-color:var(--text);color:var(--text)}
.subhead,.lc-mode-go,.lc-mode-active-tag{color:var(--dim)}
button:hover{border-color:var(--dim);color:var(--text)}
/* Encart de garde-fou : c'est le serveur qui parle (limite d'appels d'outils,
boucle détectée), pas le modèle. Pastille discrète plutôt qu'une erreur —
la génération s'est arrêtée proprement, rien n'a échoué. */
.stopnote{display:inline-flex;align-items:center;gap:7px;margin-top:4px;padding:4px 12px;
border:1px solid var(--border);border-radius:999px;background:var(--panel);
color:var(--dim);font-size:12px;line-height:1.5}
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
/* ===== Fenêtres et sélecteurs =====================================
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
.modal-card,.ask-box,.modal-box,.pe-list,.pe-adv{border-radius:3px}
.modal-card,.ask-box{box-shadow:0 8px 30px rgba(0,0,0,.12)}
/* Sélecteurs segmentés : noir plein quand c'est coché, pas de couleur. */
.pe-seg>label:has(input:checked),.seg>label:has(input:checked){background:var(--text);border-color:var(--text);color:var(--bg)}
/* Curseur de portée de l'export. Même vocabulaire visuel que la répartition GPU
(piste arrondie + pastille cerclée), mais une seule valeur : la position tout
à droite vaut « toute la conversation ». Le libellé sous la piste donne le
compte RÉEL d'échanges et le poids du fichier, la question qu'on se pose
vraiment avant d'exporter. */
.xr{position:relative;display:flex;align-items:center;height:22px}
.xr-track{width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)}
.xr-fill{height:100%;background:var(--accent);border-radius:999px;transition:width .12s}
.xr-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize}
.xr-range:focus{outline:none}
.xr-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none}
.xr-range::-moz-range-track{height:22px;background:transparent;border:none}
.xr-range::-moz-range-progress{background:transparent}
.xr-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize}
.xr-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize}
.xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.xr-range:disabled{cursor:default;opacity:.5}
/* Conversation vide : un seul message centré à la place des réglages. */
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
/* ===== Direction « Terre » : surfaces en cartes =======================
La charte pose des CARTES arrondies séparées par une gouttière, au lieu
d'une colonne à filets. Bloc en fin de feuille pour prendre le pas sur les
règles de structure d'origine sans les réécrire.
Bureau UNIQUEMENT : sur téléphone la barre latérale est un tiroir plein
écran et le fil occupe toute la largeur — y poser des cartes ne ferait que
grignoter des pixels précieux. */
@media (min-width:721px){
/* Coque : le fond « terre » se voit dans la gouttière entre les deux cartes. */
body{padding:12px;gap:12px}
html[data-pwa="1"] body{padding-top:calc(12px + env(safe-area-inset-top))}
/* Barre latérale : pile de cartes, sans filet ni bordure de colonne. */
.side{width:312px;border-right:0;padding:0;background:transparent;
display:flex;flex-direction:column;gap:12px;overflow-y:auto;overscroll-behavior:contain}
.sidecard,.side>details{background:var(--panel);border:0;border-top:0;border-radius:3px;
box-shadow:var(--shadow);margin:0;padding:2px 16px}
.sidecard{padding:15px 18px 14px;display:flex;flex-direction:column;gap:2px}
.side>details[open]{padding-bottom:14px}
.side>details>summary{height:42px}
/* L'étiquette « Paramètres » n'est plus un filet mais un intertitre entre
deux cartes : le fond de la coque fait déjà la séparation. */
.side-sep{border-top:0;margin:10px 0 0;padding:0 6px}
/* Blocs encastrés DANS une carte : un ton plus chaud, sans bordure — deux
cadres imbriqués alourdiraient inutilement. */
.side .slist{background:var(--row-bg);border-color:transparent;border-radius:3px}
/* Colonne principale : une seule grande carte qui contient fil et saisie. */
.main{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);overflow:hidden}
#chat{padding-top:26px;padding-inline:max(20px,calc(50% - 360px))}
/* Le dégradé qui fait disparaître le fil sous la carte de saisie doit fondre
vers le PANNEAU, pas vers le fond de la coque. */
#composer{background:linear-gradient(to bottom,transparent 0,var(--panel) var(--fade))}
/* Carte de saisie : posée SUR le panneau, donc un ton au-dessus, et sans
bordure — c'est l'élévation qui la détache. */
#inputbar,#ctxmeta{background:var(--row-bg-on);border-color:transparent}
#input{background:transparent}
}
/* --- Terre : densité et accents ------------------------------------- */
@media (min-width:721px){
/* Gouttière resserrée : la charte empile beaucoup de cartes, 12 px entre
chacune repoussait « Actions » loin sous la ligne de flottaison. */
.side{gap:8px}
.side>details>summary{height:38px}
/* Discussion active : un cadre sauge COMPLET, mais fin (1 px) — le liseré de
3 px sur le seul bord gauche se lisait mal et déséquilibrait la ligne. La
bordure transparente est portée par tous les états : la sélection ne décale
donc pas le contenu d'un pixel. */
.side .preset{border:1px solid transparent;border-radius:3px;background:transparent}
.side .preset.active{background:var(--row-bg-on);border-color:var(--accent)}
.side .preset:hover{background:var(--row-bg)}
/* Bulle de question : coin bas-droit rentré, comme une bulle qui pointe vers
son auteur. */
#chat .msg.user{border-radius:4px}
}
/* 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. */
/* --- Terre : largeur de lecture --------------------------------------
La mesure était plafonnée à 760 px et centrée sur la fenêtre : sur un écran
large, le fil se réduisait à une colonne étroite entourée de vide, alors que
la charte fait courir le texte sur toute la largeur du panneau.
Une seule variable pilote désormais le fil ET la carte de saisie — ils
étaient réglés séparément (450 / 380 / 360 px), donc condamnés à diverger.
La borne haute reste : au-delà, une ligne devient pénible à lire (l'œil perd
le début de la suivante). 1080 px ≈ 110 caractères, large mais tenable. */
@media (min-width:721px){
:root{--measure:min(1080px,100%)}
#chat{padding-top:26px;padding-inline:max(20px,calc(50% - var(--measure)/2))}
#composer{--col:calc(max(20px,calc(50% - var(--measure)/2)) + var(--sbw,0px))}
}
/* --- Terre : rangée de bulles techniques ------------------------------
Raisonnement, outils et mémoire ne portent qu'une étiquette tant qu'ils sont
repliés : les empiler gaspillait une ligne entière par appel et repoussait la
réponse hors de l'écran. Ils défilent donc en rangée, et reprennent la pleine
largeur dès qu'on en déplie un. */
.toolrow{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start}
.toolrow>.msg.collapsible{margin:0;max-width:100%}
.toolrow>.msg.collapsible.collapsed{width:auto}
/* Dépliée : la bulle passe seule sur sa ligne, sinon son contenu serait
comprimé dans une colonne étroite à côté d'une pastille. */
.toolrow>.msg.collapsible:not(.collapsed){flex:1 0 100%}
/* --- Terre : sections groupées en cartes ------------------------------
Une carte par section empilait onze arrondis et autant de gouttières : la
barre latérale débordait de l'écran alors que la plupart de ces lignes sont
des titres repliés d'une seule ligne. Les sections d'IA d'un côté, celles de
l'application de l'autre, tiennent maintenant chacune dans UNE carte, avec un
filet discret entre les lignes. */
@media (min-width:721px){
.sidegroup{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);
padding:2px 16px;display:flex;flex-direction:column}
/* Dans un groupe, les sections ne sont plus des cartes : elles redeviennent
des lignes, séparées par un filet — sauf la première. */
.sidegroup>details{background:transparent;box-shadow:none;border-radius:0;padding:0;
border-top:1px solid var(--border)}
.sidegroup>details:first-child{border-top:0}
.sidegroup>details[open]{padding-bottom:12px}
}
/* --- Recherche Hugging Face -------------------------------------------
Deux listes successives dans la même zone (dépôts, puis fichiers du dépôt
choisi) : une seule mise en forme sert aux deux, sinon elles divergent. La
liste est bornée en hauteur et défile — un dépôt publie facilement vingt
quantifications, et la modale ne doit pas s'étirer jusqu'à sortir de
l'écran. */
.hf-list{display:flex;flex-direction:column;max-height:220px;overflow-y:auto;
border:1px solid var(--border);border-radius:3px;margin-top:6px}
.hf-row{display:flex;align-items:center;gap:10px;padding:7px 12px;cursor:pointer;
border-top:1px solid var(--border);font-size:12px}
.hf-row:first-child{border-top:0}
.hf-row:hover{background:var(--row-bg-on)}
/* Le nom prend la place restante et se tronque : un identifiant de dépôt long
ne doit pas pousser la taille et le verdict hors de la ligne. */
.hf-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.hf-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap}
/* Verdict mémoire. La couleur seule ne suffit pas — le mot est écrit dedans,
et l'explication complète est dans l'infobulle. */
.hf-fit{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em;
text-transform:uppercase;border:1px solid transparent}
.hf-fit.ok{color:var(--ok);border-color:var(--ok)}
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
.hf-fit.trop{color:var(--err);border-color:var(--err)}
/* Verrou d'accès : ce n'est pas un verdict mémoire, mais ça se lit au même
endroit et ça décide autant de la suite — un dépôt gated ne se télécharge
pas sans jeton. */
.hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none}
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default}
/* --- Panneau « Fichiers » (droite) ------------------------------------
Symétrique des réglages à gauche : une colonne fixe qui pousse la
conversation au lieu de la recouvrir. C'est possible ici parce que la largeur
de lecture est pilotée par --measure et que #chat se recentre tout seul dans
la place qui reste — le fil se resserre, il ne passe pas dessous.
Le panneau est retiré du flux quand il est fermé (et non simplement décalé) :
sinon il conserverait sa gouttière et le fil resterait décentré. */
/* Le panneau ne défile PAS en entier : seule la liste le fait. Avec un
overflow sur le panneau, l'en-tête, le fil d'Ariane et le pied (occupation
disque, bouton .zip) s'en allaient vers le haut dès qu'il y avait quelques
fichiers — on perdait les repères et les actions en même temps. */
#files{display:none;width:320px;flex-shrink:0;border-left:1px solid var(--border);
background:var(--panel);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));
overflow:hidden;flex-direction:column;gap:10px}
html[data-files="1"] #files{display:flex}
#files-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:14}
.files-head{display:flex;align-items:center;gap:8px;flex:none}
.files-head .stitle{flex:1}
#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:4px}
#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
discussion dans le chemin dépasse largement 320 px. */
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px;flex:none}
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:3px;
background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
.files-crumb-b.on{color:var(--text);cursor:default}
.files-crumb-s{color:var(--dim);opacity:.5}
.files-list{display:flex;flex-direction:column;flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;
/* La barre de défilement ne doit pas décoller les lignes du bord : on lui
rend la gouttière plutôt que d'ajouter une marge que le panneau sans
défilement paierait aussi. */
scrollbar-gutter:stable}
/* État vide : centré dans la hauteur disponible plutôt que collé en haut d'un
grand vide — c'est le cas le plus fréquent à l'ouverture d'une discussion. */
.files-empty{font-size:12px;line-height:1.5;padding:24px 2px;text-align:center;margin:auto 0}
/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
survol : au repos on veut lire des noms de fichiers, pas une grille de
boutons. Sur écran tactile (pas de survol) elles restent visibles. */
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;font-size:12px}
.files-row:hover{background:var(--row-bg-on)}
.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
.files-row.isdir .files-ic{color:var(--accent)}
.files-mid{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
.files-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.files-meta{font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
.files-acts{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .12s}
.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
@media (hover:none){.files-acts{opacity:1}}
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
border:1px solid transparent;border-radius:3px;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:6px;border-top:1px solid var(--border);
display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:none;margin-top:auto}
/* 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:3px;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
le panneau est ouvert — l'état se lit sans regarder à droite. */
#files-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
display:flex;align-items:center;justify-content:center;border-radius:999px;
background:transparent;border:1px solid transparent;color:var(--dim)}
#files-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#files-btn:active{transform:scale(.9)}
#files-btn svg{display:block}
html[data-files="1"] #files-btn{color:var(--accent)}
/* Petit écran : tiroir par la droite, comme les réglages par la gauche. Pousser
une colonne de 320 px sur un téléphone ne laisserait rien à la conversation. */
@media (max-width:720px){
#files{position:fixed;inset:0 0 0 auto;width:85%;max-width:340px;z-index:15;
background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));
padding-right:calc(16px + env(safe-area-inset-right))}
html[data-files="1"] #files-backdrop{display:block}
}
@media (min-width:721px){
/* Dans la charte « Terre », le panneau est une carte comme les autres. */
#files{background:var(--panel);border-left-color:transparent;border-radius:3px;
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:4px;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:4px;
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}
/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
agent est en train de travailler, y compris depuis un autre appareil. */
/* ⚠️ Sélecteur en DEUX classes : `.preset>span` (plus haut) pose flex:1 sur tout
span enfant d'une ligne — l'anneau prenait sinon la moitié de la largeur et
écrasait le titre, exactement comme l'heure avant lui. */
.preset>.conv-spin,#topbar>.conv-spin{flex:none;display:inline-flex;align-items:center;
justify-content:center;line-height:0;color:var(--accent)}
/* L'animation porte sur le CONTENEUR (élément HTML), pas sur le <svg> : sur un
élément SVG, l'origine de la rotation dépend de transform-box, dont le défaut
a changé au fil des versions — l'anneau restait figé chez certains. Un span
inline-flex tourne partout, sans ambiguïté. Les deux propriétés restent
posées sur le svg pour le cas où une règle le viserait encore. */
.conv-spin{animation:conv-spin 900ms linear infinite;transform-origin:center}
.conv-spin svg{transform-box:fill-box;transform-origin:center}
@keyframes conv-spin{to{transform:rotate(360deg)}}
/* Mouvement réduit : l'anneau ne tourne plus mais respire — l'information reste,
le mouvement de rotation disparaît. */
@media (prefers-reduced-motion:reduce){
.conv-spin{animation:conv-breathe 1.6s ease-in-out infinite}
@keyframes conv-breathe{0%,100%{opacity:1}50%{opacity:.25}}
}
/* Dans l'en-tête, l'anneau précède le titre : même gabarit que les boutons
voisins pour ne pas décaler la ligne quand il apparaît et disparaît. */
#topbar>.conv-spin{width:18px}
/* 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}
/* Bouton du moniteur (« Libérer la VRAM » / « Recharger le modèle ») : poussé
à droite du titre, discret tant qu'on ne le cherche pas. En mode rechargement
il prend la couleur d'accent — le moteur est arrêté, c'est l'action qui
compte à ce moment-là. */
.perf-act{margin-left:auto;flex:none;padding:3px 8px;font-size:11px;line-height:1.4;
color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;
white-space:nowrap}
.perf-act:hover:not(:disabled){color:var(--text)}
.perf-act:disabled{opacity:.55;cursor:progress}
.perf-act.reload{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)}
.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:4px;
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:4px;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:4px;
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,#inputbar #mic,#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:4px;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,#inputbar #mic svg,#send svg,#stop svg{display:block;width:18px;height:18px}
#inputbar #attach:hover,#inputbar #files-btn:hover,#inputbar #mic: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:3px 3px 0 0}
#ctxmeta{border-radius:0 0 5px 5px}
#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:3px}
.side #side-settings .slist{background:var(--row-bg);border-radius:3px}
/* 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:4px;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:4px;
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:4px;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:3px}
#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:4px}
/* É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:3px;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:3px;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}
/* --- Mode Chat/Code (20-mode.js) ------------------------------------------
Sélecteur segmenté dans le pied de la carte du composeur, puce de
suggestion, panneau des critères, badge de rôle, carte de question. */
#mode-seg{display:inline-flex;border:1px solid var(--border);border-radius:3px;overflow:hidden;flex:none}
#mode-seg button{border:0;background:transparent;color:var(--dim);font-size:10px;letter-spacing:.04em;text-transform:uppercase;padding:2px 8px;cursor:pointer;font-family:inherit}
#mode-seg button.on{background:var(--accent-bg);color:var(--accent)}
#mode-seg button:not(.on):hover{color:var(--text)}
/* Bandeaux du composeur (chrono du tour, puce mode code, critères). Ils vivent
DANS #composer — qui est en position:absolute — et suivent donc sa colonne
(--col). Posés avant lui dans le DOM, ils restaient dans le flux de la page
et disparaissaient SOUS la carte de saisie. */
.composer-banner{margin:0 var(--col) 6px;padding:6px 12px;border:1px solid var(--border);border-radius:4px;background:var(--panel);color:var(--dim);font-size:12px;font-family:var(--mono)}
#turn-clock{display:flex;align-items:center;gap:6px;letter-spacing:.03em}
#turn-clock::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--accent);animation:tcpulse 1.4s ease-in-out infinite}
#turn-clock.done{color:var(--accent)}
#turn-clock.done::before{animation:none;opacity:.6}
@keyframes tcpulse{0%,100%{opacity:.25}50%{opacity:1}}
#code-hint{display:flex;align-items:center;gap:8px;font-size:12px}
#code-hint button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit}
#code-hint button:hover{background:var(--accent-bg)}
#code-hint #code-hint-no{border-color:var(--border);color:var(--dim);margin-left:auto}
#criteria-box{padding:0;overflow:hidden;max-height:38vh;overflow-y:auto}
#criteria-box.allpass{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))}
#criteria-head{padding:6px 12px;color:var(--dim);cursor:pointer;user-select:none;letter-spacing:.03em;position:sticky;top:0;background:var(--panel)}
#criteria-head::after{content:' ▾';opacity:.5}
#criteria-box.folded #criteria-head::after{content:' ▸'}
#criteria-box.allpass #criteria-head{color:var(--accent)}
#criteria-box.folded #criteria-list{display:none}
#criteria-list{list-style:none;margin:0;padding:0 12px 8px}
#criteria-list li{padding:2px 0;color:var(--text)}
#criteria-list li.crit-passed{color:var(--accent)}
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
#criteria-list li.crit-pending{color:var(--dim)}
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
/* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la
transcription. Le gabarit (34 px, fond au survol) vient de la règle commune
des boutons de saisie — #mic y est listé avec #attach et #files-btn. */
#mic.rec{color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
@keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}}
#mic.busy{opacity:.55}
/* ── Réglages en modale ─────────────────────────────────────────────────────
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
.switchrow, .subhead…), seuls la coque et le routage sont neufs. */
#side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:10px 14px;flex:none;
background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none;
font-size:13px;color:var(--text);cursor:pointer;text-align:left}
#side-settings-btn:hover{background:var(--bubble)}
#side-settings-btn svg{flex:none;opacity:.7}
.set-card{max-width:860px;height:min(660px,92vh)}
.set-shell{display:flex;flex:1;min-height:0}
.set-nav{flex:0 0 200px;border-right:1px solid var(--border);overflow-y:auto;
padding:10px 8px;display:flex;flex-direction:column;gap:2px}
.set-nav-h{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);margin:12px 8px 4px}
.set-nav-h:first-child{margin-top:2px}
.set-nav button{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:7px 10px;
background:none;border:0;border-radius:3px;box-shadow:none;
font-size:12.5px;color:var(--text);cursor:pointer;text-align:left}
.set-nav button:hover{background:var(--bubble)}
.set-nav button.active{background:var(--bubble);font-weight:600}
.set-nav button .sbadge{margin-left:auto}
.set-body{flex:1;min-width:0;overflow-y:auto;padding:16px}
.set-pane{display:none}
.set-pane.active{display:block}
.set-pane-h{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin:0 0 12px}
.set-pane-h>span:first-child{flex:1;min-width:0}
/* Téléphone : la nav passe en rangée défilante au-dessus du panneau, et la
modale prend tout l'écran — un panneau de réglages ne se lit pas en timbre. */
@media(max-width:720px){
.set-ov{padding:0}
.set-card{max-width:none;height:100vh;height:100dvh;max-height:none;border-radius:0;border:0}
.set-shell{flex-direction:column}
.set-nav{flex:none;flex-direction:row;align-items:center;overflow-x:auto;overflow-y:hidden;
border-right:0;border-bottom:1px solid var(--border);padding:8px;white-space:nowrap}
.set-nav-h{display:none}
.set-nav button{width:auto;flex:none}
}
/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris,
pas de capitales forcées) et borné : un nom de .gguf peut être très long,
il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */
.msg .label .modelbadge{display:inline-block;margin-left:6px;padding:0 5px;border:1px solid var(--border);border-radius:3px;color:var(--dim);font-size:9px;letter-spacing:.03em;text-transform:none;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}
.msg[data-agent-role="verifier"]{border-left:2px solid var(--accent)}
.askcard .body{display:flex;flex-direction:column;gap:8px}
.askopts{display:flex;flex-wrap:wrap;gap:6px}
.askopts button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:4px 10px;font-size:13px;cursor:pointer;font-family:inherit}
.askopts button:hover:not(:disabled){background:var(--accent-bg)}
.askopts button:disabled{opacity:.5;cursor:default}
/* Capture d'écran rendue dans la bulle de l'outil (web_screenshot) : l'image
ne dépend plus du modèle qui pense — ou non — à la recopier dans sa réponse. */
.tool-shot{margin-top:8px}
.tool-shot img{max-width:100%;max-height:420px;border:1px solid var(--border);border-radius:4px;display:block;cursor:zoom-in}
.mcp-tag.mcp-tag-preset{flex:0 1 auto;min-width:2.5em}
.task-consigne{min-height:260px;resize:vertical;line-height:1.6;padding:12px}
/* Tâches planifiées (repris de l'amont AJEAN) : ligne en cours d'exécution + modale d'édition. */
.mcp-row.task-running{border-color:var(--accent);background:var(--accent-bg);cursor:default}
.task-dot-run{animation:taskpulse 1.1s ease-in-out infinite}
@keyframes taskpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}}
.task-run-badge{font-size:10px;font-weight:600;color:var(--accent);letter-spacing:.02em}
.task-stop-btn{flex:none;margin:0;padding:3px 10px;font-size:11px;border-radius:6px;background:var(--danger-bg);border:1px solid var(--err);color:var(--err)}
.task-stop-btn:hover{background:var(--danger-bg);border-color:var(--err);color:var(--err);opacity:.85}
/* La modale des tâches emprunte tout le design de l'éditeur de preset (.pe-*) :
même largeur, même mise en carte. */
#task-modal .modal-card{max-width:520px;overflow:hidden}
#task-modal-status:empty{display:none}
/* Panneau « Dernier résultat » : le compte-rendu complet de l'IA, lisible et
défilable — pas une mini-ligne tronquée. Posé dans une carte .pe-list-like. */
.task-report{font-size:13px;line-height:1.55;color:var(--text);background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:11px 14px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word}
.task-report.report-err{border-color:var(--err);color:var(--err)}
.task-report.report-run{color:var(--dim);font-style:italic}
.task-report.report-empty{color:var(--dim)}
/* Compte-rendu rendu en markdown : c'est du HTML de blocs, donc plus de pre-wrap
(sinon les sauts de ligne du HTML généré ajoutent des lignes vides). Styles
alignés sur ceux des bulles de chat (.msg .body). */
.task-report.md-report{white-space:normal}
.md-report>*{margin:.6em 0}
.md-report>*:first-child{margin-top:0}
.md-report>*:last-child{margin-bottom:0}
.md-report h1,.md-report h2,.md-report h3,.md-report h4{font-weight:600;line-height:1.25;margin:.9em 0 .4em}
.md-report h1{font-size:1.2em}.md-report h2{font-size:1.1em}.md-report h3{font-size:1em}.md-report h4{font-size:.95em}
.md-report ul,.md-report ol{padding-left:1.4em;margin:.5em 0}
.md-report li{margin:.15em 0}
.md-report li>p{margin:0}
.md-report code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;word-break:break-word}
.md-report pre{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
.md-report pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4}
.md-report blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)}
.md-report a{color:var(--accent);text-decoration:none}
.md-report a:hover{text-decoration:underline}
.md-report table{border-collapse:collapse;margin:.5em 0}
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
</style></head><body>
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
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">
<!-- 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 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.
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. -->
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
OU par son propre chevron — c'est une section comme les autres, sinon on ne
trouvait pas comment la refermer.
C'est le seul endroit où l'on voit pourquoi un modèle refuse de charger. -->
<!-- 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. -->
</div><!-- /#side-scroll -->
<!-- Réglages : tout vit dans une modale (nav de sections à gauche, panneau à
droite) — voir js/22-settings-modal.js ; les ids historiques des sections
(svc-log-box, lc-details…) sont conservés sur les panneaux. Le bouton est
ANCRÉ en pied de barre, juste au-dessus du moniteur machine : toujours au
même endroit, quel que soit le nombre de discussions. -->
<button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog">
<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"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg>
<span>Paramètres</span>
</button>
<!-- 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">
<!-- « Libérer la VRAM » est posé LÀ, sur les jauges : c'est en les regardant
qu'on décide de rendre la carte à une autre application. Le libellé
bascule en « Recharger le modèle » dès que le moteur est arrêté — voir
paintVramBtn (js/05-status.js). -->
<div class="perf-head"><span>Performance</span><button id="vram-btn" class="perf-act" onclick="vramBtnClick()" title="décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine">Libérer la VRAM</button></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}. -->
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
le replay terminé, sinon il clignoterait au chargement de chaque page).
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
pour le dessin des lettres. -->
<div id="chat-empty" aria-hidden="true">
<div id="ce-logo"></div>
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
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" 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">
<div id="ctxmeta">
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
de code : critères, vérification, LSP). Dans le pied de carte, pas
dans la barre de saisie déjà chargée — ça se règle par discussion,
pas par message. -->
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
</div>
<div id="ctxtrack"><div id="ctx-fill"></div></div>
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
<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>
</span>
</div>
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<!-- Intensité du raisonnement : réglable ici parce que ça se décide au
moment d'écrire le message, pas dans un éditeur de preset. -->
<select id="think-effort" title="Intensité du raisonnement" aria-label="Intensité du raisonnement" onchange="setThinkEffort(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>
<option value="xhigh">maximale</option>
</select>
<!-- Dictée vocale : enregistre au micro, transcrit en local (whisper.cpp
côté serveur) et pose le texte dans le champ. Voir js/23-dictate.js.
Le navigateur n'autorise le micro qu'en HTTPS (ou localhost). -->
<button id="mic" onclick="dictateToggle()" title="dicter au micro" aria-label="dicter au micro">
<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"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/></svg>
</button>
<!-- 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 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 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" 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 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>
</aside>
<div id="toast"></div>
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;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" 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">
<button onclick="closeBenchModal()">fermer</button>
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
</div>
</div>
</div>
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
mêmes pour les deux formats : le format ne décide que du contenant. -->
<!-- Réglages : modale à deux volets — nav de sections à gauche, LE panneau
choisi à droite. Chaque section d'antan (details de la barre latérale) est
devenue un panneau .set-pane ; les ids historiques (svc-log-box,
lc-details…) sont conservés — openDetails() les résout vers
openSettings(panneau), donc pastille d'état et jobs moteur rouvrent la
modale au bon endroit. -->
<div id="settings-modal" class="modal-ov set-ov" style="display:none" onclick="if(event.target===this)closeSettings()">
<div class="modal-card set-card">
<div class="modal-head">
<strong>Paramètres</strong>
<button class="modal-x" onclick="closeSettings()" 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 class="set-shell">
<nav class="set-nav" id="set-nav" aria-label="sections des réglages">
<div class="set-nav-h">Intelligence artificielle</div>
<button data-pane="config" onclick="settingsShow('config')">Config active</button>
<button data-pane="presets" onclick="settingsShow('presets')">Presets</button>
<button data-pane="agent" onclick="settingsShow('agent')"><span>Mode agent</span><span id="agent-badge" class="sbadge"></span></button>
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
<div class="set-nav-h">Moteur</div>
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
<div class="set-nav-h">Application</div>
<button data-pane="identity" onclick="settingsShow('identity')">Identité</button>
<button data-pane="appearance" onclick="settingsShow('appearance')">Apparence</button>
<button data-pane="openai" onclick="settingsShow('openai')">Accès OpenAI</button>
<button data-pane="actions" onclick="settingsShow('actions')">Actions</button>
</nav>
<div class="set-body" id="set-body">
<section class="set-pane" data-pane="engine-log" id="svc-log-box">
<div class="set-pane-h"><span>Journal du moteur</span></div>
<div class="row" style="justify-content:flex-end;align-items:center">
<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
va quand on cherche à comprendre l'état de son installation. -->
<div class="row" style="justify-content:space-between;align-items:center;margin-top:12px">
<span class="io-cap">Emplacements</span>
</div>
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
</section>
<section class="set-pane" data-pane="config" id="cfg-details">
<div class="set-pane-h"><span>Config active</span></div>
<div id="cfg" class="slist">…</div>
</section>
<section class="set-pane" data-pane="presets">
<div class="set-pane-h"><span>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></div>
<div class="slist"><div id="presets" class="rowlist"></div></div>
</section>
<section class="set-pane" data-pane="agent">
<div class="set-pane-h"><span>Mode agent</span></div>
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
entier est grisé/inerte quand l'agent est off (voir setAgentGate). -->
<div id="mem-block" class="gated-group">
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
<div class="slist">
<label class="switchrow"><span>mode</span>
<select id="mem-mode" onchange="setMemMode()" class="sctl">
<option value="always">auto</option>
<option value="ondemand">sur demande</option>
<option value="off">désactivée</option>
</select>
</label>
<div id="mem-pages-bar" class="pages-bar" onclick="toggleMemPages()">
<span class="pages-title">Voir la mémoire <span id="mem-count" class="mem-count"></span></span>
<button class="addbtn" onclick="event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button>
</div>
<div id="mem-pages-body" hidden>
<!-- Le champ de filtre garde son rembourrage de ligne ; la liste, elle,
est à fleur de carte : ses pages sont des LIGNES séparées par un
filet, pas une pile de petites cartes. -->
<div id="mem-search-row" class="rowbody" style="display:none">
<input id="mem-search" class="mem-search" type="search" placeholder="filtrer la mémoire…" oninput="renderMemList()" style="margin:0">
</div>
<div id="mem-list" class="mem-list rowlist muted" style="font-size:12px"></div>
</div>
</div>
</div>
<div id="net-block" class="gated-group">
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
JavaScript) · « crawl4ai » = serveur externe avec Chromium headless.
Réglages propres à Crawl4AI masqués avec le moteur intégré ; l'URL et
la clé s'enregistrent en quittant le champ (onchange). -->
<div class="rowbody">
<div class="row" style="margin:0">
<select id="web-engine" class="sctl" onchange="saveWebEngine()">
<option value="go">Moteur Intégré</option>
<option value="crawl4ai">Crawl4AI</option>
</select>
</div>
<!-- Masqués AU DÉPART : le moteur par défaut est l'intégré, et ces champs
ne concernent que Crawl4AI. Affichés d'emblée puis repliés par le
chargement de l'état, ils faisaient sauter la section sous les yeux. -->
<div id="crawl-fields" style="display:none">
<div class="row">
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
</div>
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
qu'un indice sur les 4 derniers caractères. -->
<div class="row">
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
</div>
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
</div>
</div>
<div id="mcp-block" class="gated-group">
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div class="slist">
<div class="rowbody">
<!-- 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>
<div class="mcp-actions">
<button class="addbtn" onclick="openMcpCatalog()">catalogue</button>
<button class="addbtn" onclick="openMcp('')">+ ajouter un serveur</button>
</div>
</div>
</div>
</div>
<div id="param-block" class="gated-group">
<div class="subhead">Paramètres</div>
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
</div>
</div>
</section>
<section class="set-pane" data-pane="tasks" id="tasks-details">
<div class="set-pane-h"><span>Tâches planifiées</span></div>
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
conversation — elle livre son résultat via les outils de l'IA (mail via
MCP, shell…). D'où l'avertissement quand le mode agent est off : sans
lui, aucune tâche ne peut agir. -->
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="tasks-pause-toggle" onchange="toggleTasksPause()"><span class="slider"></span></span> suspendre toutes les tâches<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Interrupteur maître : coche pour que plus aucune tâche ne se déclenche. Les tâches gardent leur réglage et repartent quand tu décoches.</span></span></label>
</div>
<div id="tasks-agent-warn" class="muted" style="display:none;font-size:12px;line-height:1.5;margin:8px 2px">Le <b>mode agent</b> est désactivé : les tâches s'exécuteront mais l'IA n'aura <b>aucun outil</b> pour agir (envoyer un mail, lire des fichiers…). Active le mode agent ci-dessus.</div>
<div class="slist">
<div class="rowbody">
<div id="tasks-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucune tâche — ex. « toutes les 2 h, relève mes mails et prépare des brouillons de réponse »)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
</div>
</div>
</section>
<section class="set-pane" data-pane="sysprompt">
<div class="set-pane-h"><span>System prompt</span></div>
<div class="slist"><div class="rowbody">
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
</div></div>
</section>
<section class="set-pane" data-pane="engine" id="lc-details">
<div class="set-pane-h"><span>Moteur</span></div>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
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 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">
<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">
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
confirmation) ; « mettre à jour » télécharge et bascule. Les deux
gestes n'engagent pas pareil, chacun son bouton. -->
<button onclick="engCheck()">vérifier les mises à jour</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
la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
alors que compiler donne du CUDA natif. -->
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">llama.cpp personnalisé</div>
<div class="lc-mode-d">un fork de llama.cpp pour modèles spéciaux (quant ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<div id="lc-job" class="rowbody" style="display:none">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
<pre id="lc-log" hidden></pre>
</div>
</div>
<!-- Ces boutons pilotent le service qui fait tourner llama-server : leur
place est ici, avec le moteur, et pas dans une section « Service » à
part qui ne contenait qu'eux. Le dernier, « libérer la VRAM », fait
plus qu'arrêter : il éteint aussi la dictée et annonce la mémoire
vidéo rendue (c'est le même geste que le bouton du moniteur). -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).<br><b>Libérer la VRAM</b> arrête le moteur <i>et</i> la dictée, puis annonce la mémoire vidéo rendue : c'est ce qu'il faut quand une autre application réclame la carte. Le même bouton vit sur les jauges, en pied de barre latérale.</span></span></div>
<div class="slist"><div class="rowbody">
<div class="row" style="margin:0">
<button onclick="act('start')">démarrer</button>
<button onclick="act('restart')">redémarrer</button>
<button onclick="act('stop')">arrêter</button>
<button onclick="vramUnload(false)" title="décharge le modèle et rend la mémoire vidéo à la machine">libérer la VRAM</button>
</div>
</div></div>
</section>
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé et le GPU occupé
sont des propriétés de la machine, identiques pour tous les onglets.
Voir js/24-dictate-settings.js et internal/loki/dictate_*.go. -->
<section class="set-pane" data-pane="dictee">
<div class="set-pane-h"><span>Dictée</span></div>
<div class="subhead">Matériel<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">whisper tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après <b>dix minutes</b> sans dictée — il ne garde pas la VRAM occupée pour rien. Le GPU choisi est transmis par <code>CUDA_VISIBLE_DEVICES</code>.</span></span></div>
<div class="slist">
<label class="switchrow"><span>processeur ou GPU</span>
<select id="dic-device" class="sctl"></select>
</label>
</div>
<div class="subhead">Modèle<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les modèles ne sont <b>pas</b> dans l'image : ils sont téléchargés à la demande dans <code>/data/whisper/</code> et survivent aux recréations du conteneur. Plus le modèle est gros, mieux il comprend — et plus il occupe de mémoire.</span></span></div>
<div class="slist">
<label class="switchrow"><span>modèle</span>
<select id="dic-model" class="sctl"></select>
</label>
<div class="rowbody">
<button id="dic-dl" class="btn" onclick="dictateDownload()">Télécharger ce modèle</button>
<span id="dic-dl-state" class="dim"></span>
</div>
</div>
<div class="subhead">Reconnaissance</div>
<div class="slist">
<label class="switchrow"><span>langue</span>
<select id="dic-lang" class="sctl">
<option value="fr">français</option>
<option value="en">anglais</option>
<option value="de">allemand</option>
<option value="es">espagnol</option>
<option value="it">italien</option>
<option value="auto">détection automatique</option>
</select>
</label>
<label class="switchrow"><span>réactivité<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Longueur des tranches transcrites au fil de la dictée. Court = le texte arrive plus tôt, avec moins de contexte donc plus d'erreurs. Long = mieux reconnu, mais plus d'attente.</span></span></span>
<select id="dic-react" class="sctl">
<option value="court">court</option>
<option value="moyen">moyen</option>
<option value="long">long</option>
</select>
</label>
<div class="rowbody">
<button class="btn primary" onclick="dictateSave()">Enregistrer</button>
<span id="dic-save-state" class="dim"></span>
</div>
</div>
<div class="subhead">Vérifier</div>
<div class="slist">
<div class="rowbody">
<button id="dic-test" class="btn" onclick="dictateTestMic()">Tester le micro (3 s)</button>
</div>
<div id="dic-test-out" class="dim"></div>
<div id="dic-state" class="dim"></div>
</div>
</section>
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
l'application elle-même. -->
<!-- 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. -->
<section class="set-pane" data-pane="identity" id="ident-details">
<div class="set-pane-h"><span>Identité</span></div>
<div class="slist"><div class="rowbody">
<div class="pe-row">
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
<!-- autocomplete/data-*-ignore : sans ça, les gestionnaires de mots de
passe prennent ce champ pour un identifiant de connexion et
recouvrent la saisie de comptes enregistrés. -->
<span class="pe-row-c"><input id="p-username" class="pe-inp" maxlength="24" placeholder="toi"
autocomplete="off" name="loki-nickname" data-1p-ignore data-lpignore="true" data-form-type="other"
oninput="saveIdentity()"></span>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Ton avatar</span>
<div id="p-avatar-user" class="avpick"></div>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Avatar de Loki</span>
<div id="p-avatar-ai" class="avpick"></div>
</div>
</div></div>
</section>
<section class="set-pane" data-pane="appearance">
<div class="set-pane-h"><span>Apparence</span></div>
<div class="slist">
<label class="switchrow rev"><span>mode sombre</span>
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
</label>
</div>
<div class="subhead">Affichage</div>
<div class="slist">
<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-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>
</section>
<section class="set-pane" data-pane="openai">
<div class="set-pane-h"><span>Accès OpenAI</span></div>
<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 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>
<!-- 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="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 — 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>
<button class="iconbtn" onclick="copyApiKey()" title="copier la clé"><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 class="row" style="margin-bottom:0">
<button onclick="apiKeyAction('generate')">générer</button>
<button onclick="apiKeySet()">définir…</button>
<button onclick="apiKeyAction('clear')">retirer</button>
</div>
</div></div>
</section>
<section class="set-pane" data-pane="actions" id="upd-details">
<div class="set-pane-h"><span>Actions</span></div>
<div class="slist"><div class="rowbody">
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
bouton exporter, plus personne ne peut relire/archiver ses échanges. -->
<div class="row" style="margin:0">
<button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button>
<button onclick="openExportModal()" title="choisir le format et ce qu'on emporte">⤓ exporter…</button>
</div>
<div id="upd-msg" class="muted"></div>
<!-- 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()" 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>
</section>
</div><!-- /.set-body -->
</div><!-- /.set-shell -->
</div>
</div>
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
<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" 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. -->
<div id="x-empty" class="xempty" style="display:none">
<div class="xempty-i">⤓</div>
<div>Rien à exporter</div>
<div class="pe-note">La conversation est vide.</div>
</div>
<div id="x-body" class="pe-body" style="gap:20px">
<div class="pe-group">
<div class="pe-gh">Format</div>
<div class="pe-seg">
<label><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()"><span>Markdown</span><span class="be-note">à lire</span></label>
<label><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()"><span>JSON</span><span class="be-note">à retraiter</span></label>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Portée</div>
<div class="pe-list">
<div class="pe-row stack">
<div class="xr">
<div class="xr-track"><div class="xr-fill" id="x-fill"></div></div>
<input type="range" class="xr-range" id="x-turns" min="1" max="1" value="1" oninput="onExportPreview()" aria-label="nombre d'échanges à exporter">
</div>
<div class="pe-note" id="x-scope"></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Contenu</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Raisonnements<span class="pe-sub">la réflexion du modèle, souvent plus longue que la réponse</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-reasoning" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Appels d'outils<span class="pe-sub">commandes, lectures et écritures de fichiers</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-tools" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Sorties des outils<span class="pe-sub">ce que chaque outil a répondu</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-results" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div class="pe-note" id="x-note"></div>
</div>
<div class="modal-foot" style="justify-content:flex-end">
<button class="pe-btn" onclick="closeExportModal()">annuler</button>
<button class="pe-btn primary" id="x-go" onclick="runExport()">⤓ télécharger</button>
</div>
</div>
</div>
<div id="ask-modal" class="ask-overlay" style="display:none" onclick="if(event.target===this)askResolve(false)">
<div class="ask-box">
<div id="ask-title" class="ask-title"></div>
<div id="ask-msg" class="ask-msg"></div>
<input id="ask-input" class="ask-input" style="display:none">
<!-- Option facultative de la confirmation (ex. « supprimer aussi le .gguf ») :
sa place est ICI, dans la boîte qui pose la question, plutôt que dans le
pied de l'éditeur où elle occupait de la place en permanence. -->
<label id="ask-check" class="pe-delmodel" style="display:none"><input type="checkbox" id="ask-check-input"><span id="ask-check-label"></span></label>
<div class="ask-actions">
<button id="ask-cancel" onclick="askResolve(false)">Annuler</button>
<button id="ask-ok" class="ask-ok" onclick="askResolve(true)">Confirmer</button>
</div>
</div>
</div>
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
<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"><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 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>
</div>
<div class="fld">
<label class="fld-l" for="lc-custom-url">Installer depuis un dépôt Git</label>
<input id="lc-custom-url" class="ctl" placeholder="https://github.com/owner/llama.cpp-fork">
</div>
<div class="fld">
<label class="fld-l" for="lc-custom-name">Nom <span class="opt">(optionnel — déduit de l'URL sinon)</span></label>
<input id="lc-custom-name" class="ctl" placeholder="llama.cpp-prism">
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="closeCustomBackends()">fermer</button>
<button class="btn-save" onclick="lcInstallCustom()">Installer &amp; compiler</button>
</div>
</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"><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">
<label class="fld-l" for="mcp-name">Nom <span class="opt">(identifiant du serveur, ex: mon-serveur-fichiers)</span></label>
<input id="mcp-name" class="ctl" placeholder="mon-serveur">
</div>
<div class="fld">
<label class="fld-l">Transport</label>
<div class="seg">
<label><input type="radio" name="mcp-transport" value="stdio" onchange="mcpTransportUI('stdio')"><span>stdio (local)</span></label>
<label><input type="radio" name="mcp-transport" value="http" onchange="mcpTransportUI('http')"><span>http (distant)</span></label>
</div>
</div>
<div id="mcp-stdio-fields">
<div class="fld">
<label class="fld-l" for="mcp-command">Commande <span class="opt">(exécutable, ex: npx, uvx, python)</span></label>
<input id="mcp-command" class="ctl" placeholder="npx">
</div>
<div class="fld">
<label class="fld-l" for="mcp-args">Arguments <span class="opt">(un par ligne)</span></label>
<textarea id="mcp-args" class="ctl ta" spellcheck="false" style="min-height:70px" placeholder="-y&#10;@scope/mon-serveur-mcp"></textarea>
</div>
<div class="fld">
<label class="fld-l" for="mcp-env">Variables d'environnement <span class="opt">(KEY=valeur, une par ligne — optionnel)</span></label>
<textarea id="mcp-env" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="API_TOKEN=xxxx"></textarea>
</div>
</div>
<div id="mcp-http-fields" style="display:none">
<div class="fld">
<label class="fld-l" for="mcp-url">URL <span class="opt">(endpoint Streamable HTTP)</span></label>
<input id="mcp-url" class="ctl" placeholder="https://exemple.com/mcp">
</div>
<div class="fld">
<label class="fld-l" for="mcp-headers">En-têtes <span class="opt">(Nom: valeur, un par ligne — ex: Authorization: Bearer …)</span></label>
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400;font-family:var(--font)"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
<div id="mcp-tools-list" class="mcp-tools-list"></div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l">
<button id="mcp-del" class="btn-danger" onclick="deleteMcp()" style="display:none">supprimer</button>
</div>
<div class="foot-r">
<button onclick="closeMcp()">annuler</button>
<button class="btn-save" onclick="saveMcp()">enregistrer</button>
</div>
</div>
</div>
</div>
<div id="task-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeTask()">
<div class="modal-card">
<div class="modal-head">
<strong id="task-modal-title">Tâche planifiée</strong>
<button class="modal-x" onclick="closeTask()" aria-label="Fermer">×</button>
</div>
<div class="pe-body">
<div id="task-result-block" class="pe-group" style="display:none">
<div class="pe-gh">Dernier résultat <span id="task-result-when" class="pe-sub" style="display:inline;margin:0"></span></div>
<div id="task-result" class="task-report"></div>
</div>
<div class="pe-group">
<div class="pe-gh">Nom</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="task-name" class="pe-inp" placeholder="Nom de la tâche"></div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Consigne</div>
<textarea id="task-prompt" class="pe-inp task-consigne" spellcheck="false" placeholder="Ce que l'IA doit faire, et comment livrer le résultat.&#10;&#10;Tu peux détailler librement : contexte, étapes, format du compte-rendu attendu…"></textarea>
</div>
<div class="pe-group">
<div class="pe-gh">Preset</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Modèle utilisé</span>
<span class="pe-row-c"><span class="pe-selc"><select id="task-preset"></select></span></span>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Fréquence</div>
<div class="pe-seg">
<label><input type="radio" name="task-freq-mode" value="interval" onchange="taskFreqUI('interval')"><span>Intervalle</span></label>
<label><input type="radio" name="task-freq-mode" value="cron" onchange="taskFreqUI('cron')"><span>Horaire précis</span></label>
</div>
<div class="pe-list" style="margin-top:8px">
<div id="task-interval-fields" class="pe-row">
<span class="pe-row-l">Toutes les</span>
<span class="pe-row-c">
<input id="task-interval-n" class="pe-val" type="number" min="1" value="2" style="width:54px">
<span class="pe-selc"><select id="task-interval-unit" onchange="taskUnitUI()">
<option value="m">minutes</option>
<option value="h" selected>heures</option>
<option value="d">jours</option>
</select></span>
</span>
</div>
<div id="task-time-row" class="pe-row" style="display:none">
<span class="pe-row-l">À</span>
<span class="pe-row-c"><input id="task-time" class="pe-val" type="time" value="09:00" style="width:110px"></span>
</div>
<div id="task-cron-fields" class="pe-row stack" style="display:none">
<input id="task-cron" class="pe-inp" placeholder="0 9 * * 1-5" spellcheck="false" style="font-family:var(--mono)">
<div class="pe-note"><code>minute heure jour mois jour-semaine</code></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Accès</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Accès à la mémoire</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-mem" checked><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Accès au web</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-web" checked><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Tâche active</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-enabled" checked><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div id="task-modal-status" class="pe-seg-note" style="padding:0 6px"></div>
</div>
<div class="pe-foot">
<div class="foot-l">
<button id="task-del" class="pe-del" onclick="deleteTaskUI()" title="supprimer" aria-label="supprimer" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
</div>
<div class="foot-r">
<button id="task-run" class="pe-btn" onclick="runTaskNow()" style="display:none">tester maintenant</button>
<button class="pe-btn" onclick="closeTask()">annuler</button>
<button class="pe-btn primary" onclick="saveTask()">enregistrer</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
<strong id="modal-title">Preset</strong>
<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
le formulaire, qui reste en place (invisible) : la carte garde sa taille
finale, donc pas de saut au moment où le contenu apparaît. -->
<div class="modal-load"><span class="spinner"></span></div>
<div class="pe-body">
<div class="pe-group">
<div class="pe-gh">Nom</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="m-name" class="pe-inp" placeholder="Nom du preset"></div>
</div>
</div>
<div id="m-model-row" style="display:none;flex-direction:column;gap:26px">
<div class="pe-group">
<div class="pe-gh">Modèle</div>
<div class="pe-list">
<div class="pe-row stack">
<span class="pe-row-l">Fichier</span>
<span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div>
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
reste là : c'est le mode expert (dépôt privé, fichier exotique,
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
surtout il propose le projecteur vision DU MÊME DÉPÔT — le seul
qui corresponde au modèle choisi. -->
<div class="pe-row stack">
<span class="pe-row-l">Chercher un modèle<span class="pe-sub">sur Hugging Face — dépôts GGUF uniquement</span></span>
<div class="pe-inpbtn">
<input id="hf-q" class="pe-inp" placeholder="qwen3.8, mistral, gemma…" onkeydown="if(event.key==='Enter'){event.preventDefault();hfSearch();}">
<button id="hf-go" onclick="hfSearch()" title="Chercher">⌕</button>
</div>
<div id="hf-out"></div>
</div>
<div class="pe-row stack">
<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" 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>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
<div class="pe-row">
<span class="pe-row-l">Destination</span>
<span class="pe-selc"><select id="m-hf-dir"></select></span>
</div>
<div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()">
<span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span>
<span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span>
</div>
<div id="m-dirs-body" class="pe-row stack" style="display:none">
<div id="m-dirs-list" class="pe-note"></div>
<div class="pe-inpbtn">
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
</div>
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div>
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
touche qu'une fois, le jour où un dépôt à accès restreint refuse
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
dans ce groupe plutôt qu'à côté de la clé API du serveur, qui ne
parle pas du tout de la même chose. -->
<div id="m-hft-toggle" class="pe-row" style="cursor:pointer" onclick="toggleHFToken()">
<span class="pe-row-l">Jeton Hugging Face<span class="pe-sub">dépôts à accès restreint (« gated ») ou privés</span></span>
<span class="pe-row-c"><span id="m-hft-state" class="pe-sub"></span><span id="m-hft-caret" class="pe-caret"></span></span>
</div>
<div id="m-hft-body" class="pe-row stack" style="display:none">
<div class="pe-inpbtn">
<input id="m-hft-input" class="pe-inp" type="password" autocomplete="off" spellcheck="false" placeholder="hf_…" onkeydown="if(event.key==='Enter'){event.preventDefault();saveHFToken();}">
<button id="m-hft-save" onclick="saveHFToken()" title="Vérifier et enregistrer" aria-label="Enregistrer">✓</button>
</div>
<div id="m-hft-note" class="pe-note"></div>
<button id="m-hft-clear" class="pe-link" onclick="clearHFToken()" style="display:none">Retirer le jeton</button>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Moteur</div>
<!-- « Image » n'apparaît qu'en conteneur (moteur fourni), où
« Précompilé » et « Compilé » sont au contraire masqués : ils
désignent des installations que Loki ne fait pas ici. -->
<div class="pe-seg">
<label id="be-lab-image" style="display:none"><input type="radio" name="m-be" value="image" onchange="onBackendMode('image')"><span>Image</span><span class="be-note" id="be-image-note"></span></label>
<label id="be-lab-fast"><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Précompilé</span><span class="be-note" id="be-fast-note"></span></label>
<label id="be-lab-opt"><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Compilé</span><span class="be-note" id="be-opt-note"></span></label>
<label id="be-lab-custom"><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
</div>
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
<div class="pe-row">
<span class="pe-row-l">Backend</span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-backend-detected" onchange="onPickDetected()"></select></span></span>
</div>
<div class="pe-row stack">
<input id="m-backend-path" class="pe-inp" oninput="onCustomPath()" placeholder="ou un chemin vers llama-server(.exe)">
<div class="pe-note" id="be-drop-hint"></div>
</div>
</div>
</div>
<!-- Cartes graphiques : la liste vient du moteur choisi juste au-dessus,
car les noms ET l'ordre dépendent du backend compilé dedans. Le
groupe reste masqué tant qu'on n'a pas AU MOINS DEUX cartes : avec
une seule, il n'y a rien à arbitrer. -->
<div class="pe-group" id="m-gpu-group" hidden>
<div class="pe-gh">Cartes graphiques</div>
<div class="pe-list" id="m-gpu-list"></div>
<div class="pe-note" id="m-gpu-note"></div>
</div>
</div>
<!-- Une seule liste : les anciens « réglages avancés » repliés sont
désormais à la suite. Les valeurs d'abord, puis TOUS les interrupteurs
groupés en fin de liste — chercher un toggle perdu entre deux champs
numériques n'aidait personne. -->
<div id="m-settings-row" class="pe-group" style="display:none">
<div class="pe-gh">Réglages</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Contexte</span>
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-threads" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads (batch)<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">BATCH</span>
<span class="pe-row-c"><input id="s-batch" class="pe-val" type="number" min="0" step="256" placeholder="2048" oninput="cfgWriteKey('BATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">UBATCH</span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Cache KV<span class="pe-sub">compression du contexte</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-kv" onchange="cfgWriteKey('KV_TYPE', this.value)">
<option value="">f16 (max qualité)</option>
<option value="q8_0">q8_0 (léger)</option>
<option value="q4_0">q4_0 (très léger)</option>
<option value="q5_1">q5_1</option>
<option value="bf16">bf16</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : le type vient de la liste que llama-server
accepte pour --spec-type. Le nombre de jetons anticipés
(--spec-draft-n-max) n'a de sens qu'une fois un type choisi, donc
sa ligne n'apparaît qu'à ce moment. -->
<div class="pe-row">
<span class="pe-row-l">Décodage spéculatif<span class="pe-sub">anticipe des jetons pour accélérer</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (intégré au modèle)</option>
<option value="draft-eagle3">EAGLE-3</option>
<option value="draft-simple">modèle brouillon séparé</option>
<option value="draft-dflash">dFlash</option>
<option value="draft-dspark">dSpark</option>
</optgroup>
<optgroup label="N-grammes (sans modèle brouillon)">
<option value="ngram-simple">n-grammes</option>
<option value="ngram-mod">n-grammes (mod)</option>
<option value="ngram-cache">n-grammes (cache)</option>
<option value="ngram-map-k">n-grammes (map-k)</option>
<option value="ngram-map-k4v">n-grammes (map-k4v)</option>
</optgroup>
</select></span></span>
</div>
<div class="pe-row" id="s-spec-n-row" style="display:none">
<span class="pe-row-l">Jetons anticipés<span class="pe-sub">par étape — défaut 3</span></span>
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
</div>
<div class="pe-row">
<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">défaut de ce preset — ajustable à la volée depuis la barre de saisie</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>
<option value="xhigh">maximale</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>
</div>
<div class="pe-row">
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<!-- Échantillonnage (repris de l'amont AJEAN v0.9.5/v0.9.6) : ces valeurs
partent dans CHAQUE requête de complétion, pas sur la ligne de commande
du moteur — c'est le seul endroit où elles prennent effet. Un champ vide
n'envoie rien, llama-server garde son défaut. Les valeurs recommandées
dépendent du modèle (Qwen3.8 en réflexion : temp 1.0, top_p 0.95,
top_k 20, min_p 0). -->
<div id="m-sampling-row" class="pe-group" style="display:none">
<div class="pe-gh">Échantillonnage</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Température<span class="pe-sub">vide = défaut du modèle</span></span>
<span class="pe-row-c"><input id="s-temp" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('TEMP', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">top_p<span class="pe-sub">noyau de probabilité</span></span>
<span class="pe-row-c"><input id="s-topp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('TOP_P', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">top_k<span class="pe-sub">0 = désactivé</span></span>
<span class="pe-row-c"><input id="s-topk" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="cfgWriteKey('TOP_K', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">min_p<span class="pe-sub">seuil de probabilité</span></span>
<span class="pe-row-c"><input id="s-minp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('MIN_P', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Pénalité de présence<span class="pe-sub">pousse vers de nouveaux mots (0 = off)</span></span>
<span class="pe-row-c"><input id="s-presp" class="pe-val" type="number" min="0" max="2" step="0.1" placeholder="—" oninput="cfgWriteKey('PRESENCE_PENALTY', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Pénalité de répétition<span class="pe-sub">anti-répétition (1 = neutre)</span></span>
<span class="pe-row-c"><input id="s-reppen" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('REPEAT_PENALTY', this.value)"></span>
</div>
</div>
</div>
<div id="m-raw" class="pe-group">
<div class="pe-gh" id="m-raw-head">Configuration</div>
<div class="pe-list">
<div id="m-raw-toggle" class="pe-row" style="cursor:pointer" onclick="toggleRaw()">
<span class="pe-row-l">Éditer le fichier de configuration<span class="pe-sub">texte brut</span></span>
<span class="pe-row-c"><span id="m-raw-caret" class="pe-caret"></span></span>
</div>
<div id="m-raw-body" class="pe-row stack" style="display:none">
<textarea id="m-content" class="pe-inp" spellcheck="false"></textarea>
</div>
</div>
</div>
</div>
<!-- Pied compact, sur UNE ligne : la suppression est une icône (action rare et
destructrice, elle n'a pas à peser autant que « enregistrer »), et sa case
« supprimer aussi le .gguf » se réduit à l'extension, le libellé complet
restant en infobulle. -->
<div class="pe-foot">
<div class="foot-l">
<button id="m-del" class="pe-del" onclick="delItem()" title="supprimer" aria-label="supprimer"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
</div>
<div class="foot-r">
<button class="pe-btn" onclick="closeModal()">annuler</button>
<button class="pe-btn primary" onclick="saveItem()">enregistrer</button>
</div>
</div>
</div>
</div>
<script src="marked.min.js"></script>
<script>
// marked: GFM on, no auto-breaks (single newlines stay inline).
marked.setOptions({ gfm: true, breaks: false });
// Pre-process to fix common LLM markdown mistakes before handing to marked:
// - code fences glued to preceding text on the same line ("foo ```ruby ...")
// - fences without a closing newline
// - 3+ consecutive blank lines collapsed to one (LLMs love padding)
function fixMd(s){
if(!s) return '';
// Force a newline before a fence that's been glued to preceding text
// (common LLM mistake: "...crée le fichier et```ruby"). We do NOT split
// *after* the fence — the part after the fence is the language identifier.
s = s.replace(/([^\n])(\s*)(```+|~~~+)(?=\w*\s*\n)/g, '$1\n$3');
// Collapse runs of blank lines that LLMs love to emit.
s = s.replace(/\n{3,}/g, '\n\n');
return s;
}
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
// modèle. En cours de streaming, la parenthèse fermante manque encore → aucune
// correspondance, donc pas d'encart qui clignote à chaque token.
const NOTICE_RE=/\[stop\s*:\s*([^\]]+)\]/g;
function markNotices(root){
const walk=document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const hits=[];
while(walk.nextNode()){
const n=walk.currentNode;
// Un exemple dans un bloc de code reste du code.
if(n.parentElement && n.parentElement.closest('pre,code')) continue;
NOTICE_RE.lastIndex=0;
if(NOTICE_RE.test(n.nodeValue)) hits.push(n);
}
hits.forEach(n=>{
const frag=document.createDocumentFragment();
let rest=n.nodeValue, m;
NOTICE_RE.lastIndex=0;
while((m=NOTICE_RE.exec(rest))!==null){
if(m.index) frag.appendChild(document.createTextNode(rest.slice(0,m.index)));
const tag=document.createElement('span');
tag.className='stopnote'; tag.textContent=m[1].trim();
frag.appendChild(tag);
rest=rest.slice(m.index+m[0].length);
NOTICE_RE.lastIndex=0;
}
if(rest) frag.appendChild(document.createTextNode(rest));
n.parentNode.replaceChild(frag, n);
});
}
let msgs = [];
let busy = false;
function toggleSide(){ document.getElementById('side').classList.toggle('open'); document.getElementById('backdrop').classList.toggle('open'); document.body.classList.toggle('drawer-open'); }
// Prompt système : persisté CÔTÉ SERVEUR (/api/sysprompt, partagé entre
// appareils, utilisé par la génération serveur). localStorage = simple cache
// d'affichage hors-ligne. Debounce : on n'écrit qu'après une pause de frappe.
let _sysT = null;
function saveSys(){
const v = document.getElementById('sysprompt').value;
localStorage.setItem('loki.sys', v);
clearTimeout(_sysT);
_sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600);
}
async function loadSys(){
try{
const d = await jget('/api/sysprompt');
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); }
}catch(e){}
}
// ===== Apparence =============================================================
// Un seul thème, en deux variantes : claire (par défaut) et sombre. La variante
// est portée par l'attribut data-theme de <html> ('' ou 'dark') ; tout le reste
// est du CSS (voir la section « Palette »). Persistée dans localStorage pour
// être appliquée sans clignotement au chargement, puis alignée sur le serveur
// par loadPrefs() — voir 02-prefs.js.
function applyTheme(id){
// Tolère les anciens identifiants (loki, gpt-dark, soft…) : seule compte
// désormais la variante, déduite du suffixe.
const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark'));
document.documentElement.setAttribute('data-theme', dark?'dark':'light');
try{ localStorage.setItem('loki-theme', dark?'dark':'light'); }catch(e){}
const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark;
}
function toggleThemeDark(){
applyTheme(document.getElementById('theme-dark').checked?'dark':'light');
savePrefs();
}
function initTheme(){
let id='light'; try{ id=localStorage.getItem('loki-theme')||'light'; }catch(e){}
applyTheme(id);
}
// ===== Affichage ============================================================
// 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-stats', label:'masquer la vitesse de génération'},
];
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
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;
}
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){
VIEW_OPTS.forEach(o=>{
let v=null; try{ v=localStorage.getItem('loki-'+o.id); }catch(e){}
applyView(o.id, v==='1');
});
}
// ===== Persistance côté serveur (partagée entre appareils) ==================
// L'apparence est aussi enregistrée sur la machine loki (/api/prefs) : ainsi le
// thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le
// localStorage reste utilisé pour appliquer instantanément au chargement (sans
// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité).
function savePrefs(){
let theme='light';
try{ theme=localStorage.getItem('loki-theme')||'light'; }catch(e){}
const p={theme};
VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; });
jpost('/api/prefs', p).catch(()=>{});
}
async function loadPrefs(){
try{
const p=await jget('/api/prefs');
if(p && p.ok && p.prefs){
// Un thème enregistré par une version précédente (loki, gpt-dark…) est
// normalisé par applyTheme ; on réenregistre pour purger le serveur.
if(p.prefs.theme){
applyTheme(p.prefs.theme);
if(p.prefs.theme!=='light' && p.prefs.theme!=='dark') savePrefs();
}
VIEW_OPTS.forEach(o=>{
const v=p.prefs[o.id.replace('-','_')];
if(v!==undefined) applyView(o.id, v==='1');
});
}
}catch(e){}
}
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('loki.sys') || ''; loadSys(); restoreChat(); });
// ─── 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"/>',
// Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité,
// le dessin reste au même trait que le reste du jeu.
spinner: '<path d="M12 3.5a8.5 8.5 0 1 1-8.5 8.5"/>',
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;
}
// Pastille d'état d'une section du menu (mode agent, internet, MCP, distant).
// `state` : true = actif (pilule pleine), false = inactif (contour), 'warn'/'err'
// = état signalé, null = pastille masquée. Un seul endroit décide de leur allure.
function setBadge(id, state, text){
const el = typeof id==='string' ? document.getElementById(id) : id;
if(!el) return;
el.className = 'sbadge' + (state===true ? ' on' : (state==='warn' || state==='err') ? ' '+state : '');
el.textContent = state===null || state===undefined ? '' : (text||'');
}
// Ouverture/fermeture des modales. Le display seul apparaissait d'un bloc : on
// pose la classe .show une frame APRÈS l'affichage pour que la transition CSS
// parte de son état initial (sans ce reflow forcé, le navigateur applique tout
// d'un coup et l'animation ne joue pas). À la fermeture on attend la fin de la
// transition avant de repasser en display:none.
function showModal(id){
const el = typeof id==='string' ? document.getElementById(id) : id;
if(!el) return;
el.style.display='flex';
void el.offsetHeight;
el.classList.add('show');
}
function hideModal(id){
const el = typeof id==='string' ? document.getElementById(id) : id;
if(!el) return;
el.classList.remove('show');
// Rouverte entre-temps ? On ne la cache surtout pas.
setTimeout(()=>{ if(!el.classList.contains('show')) el.style.display='none'; }, 180);
}
function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); }
// Modales natives (askConfirm/askPrompt/askAlert) — remplacent confirm()/prompt()/
// alert() par de vraies boîtes stylées. Chacune renvoie une Promise : askConfirm →
// bool, askPrompt → string|null (null si annulé), askAlert → void. Échap/clic dehors
// = annuler ; Entrée = valider (sur prompt aussi).
let _askResolver=null, _askKind='confirm', _askCheck=false;
// État de la case optionnelle de la DERNIÈRE confirmation (opts.check). Lu par
// l'appelant juste après le await — la Promise, elle, ne renvoie que oui/non.
function askChecked(){ return _askCheck; }
function askResolve(ok){
if(!_askResolver) return;
const r=_askResolver; _askResolver=null;
_askCheck = ok && document.getElementById('ask-check-input').checked;
hideModal('ask-modal');
document.removeEventListener('keydown', _askKey, true);
if(_askKind==='prompt') r(ok ? document.getElementById('ask-input').value : null);
else r(ok);
}
function _askKey(e){
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); askResolve(false); }
else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); }
}
function _openAsk(kind, message, opts){
// Compat 2 conventions : (message, opts) [UI loki] ET l'objet unique
// {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html,
// où cette fonction remplace le window.askConfirm du bootstrap boîte noire).
if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; }
opts=opts||{};
// Une modale déjà ouverte ne doit pas être écrasée en silence : son appelant
// attend une réponse, et l'écraser laissait sa promesse pendante POUR
// TOUJOURS (requête suspendue, bouton figé). On l'annule proprement d'abord.
if(_askResolver){ const prev=_askResolver; _askResolver=null; prev(_askKind==='prompt' ? null : false); }
_askKind=kind;
document.getElementById('ask-title').textContent = opts.title || (kind==='alert'?'Info':kind==='prompt'?'Saisie':'Confirmation');
document.getElementById('ask-msg').textContent = message||'';
const inp=document.getElementById('ask-input');
if(kind==='prompt'){ inp.style.display=''; inp.value=opts.default||''; inp.placeholder=opts.placeholder||''; }
else inp.style.display='none';
// Case facultative (ex. « supprimer aussi le fichier .gguf »).
const chk=document.getElementById('ask-check');
chk.style.display = opts.check ? 'inline-flex' : 'none';
document.getElementById('ask-check-label').textContent = opts.check || '';
document.getElementById('ask-check-input').checked = false;
_askCheck=false;
const cancel=document.getElementById('ask-cancel'), ok=document.getElementById('ask-ok');
cancel.style.display = kind==='alert' ? 'none' : '';
cancel.textContent = opts.cancelText || 'Annuler';
ok.textContent = opts.okText || (kind==='alert'?'OK':kind==='prompt'?'Valider':'Confirmer');
ok.classList.toggle('danger', !!opts.danger);
showModal('ask-modal');
document.addEventListener('keydown', _askKey, true);
setTimeout(()=>{ const f = kind==='prompt'?inp:ok; f.focus(); if(kind==='prompt') inp.select(); }, 30);
return new Promise(res=>{ _askResolver=res; });
}
function askConfirm(message,opts){ return _openAsk('confirm',message,opts); }
function askPrompt(message,opts){ return _openAsk('prompt',message,opts); }
function askAlert(message,opts){ return _openAsk('alert',message,opts); }
// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*.
// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage.
let TOKEN = localStorage.getItem('loki.key') || '';
function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; }
// Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link.
// Garde les chemins absolus (/api/…) corrects derrière le tunnel.
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le
// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint
// dès qu'on laisse traîner plusieurs onglets Loki) reste suspendue POUR TOUJOURS :
// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher.
// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal
// et ne sont donc jamais concernés.
const API_TIMEOUT_MS = 30000;
async function jfetch(u, opts){
opts = opts || {};
opts.headers = authHeaders(opts.headers);
if(u.charAt(0) === '/') u = API_BASE + u;
let timer=null;
if(!opts.signal){
const ac=new AbortController();
opts.signal=ac.signal;
timer=setTimeout(()=>ac.abort(), API_TIMEOUT_MS);
}
let r;
try{ r = await fetch(u, opts); }
catch(e){
if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Loki ouverts ?)'); }
throw e;
}
finally{ if(timer) clearTimeout(timer); }
if(r.status === 401){
const k = await askKeyOnce();
if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
}
return r;
}
// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels.
//
// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le
// rafraîchissement périodique en relance autant. Sans ce partage, CHAQUE 401
// rouvrait la modale, laquelle vide son champ à l'ouverture : impossible de
// taper la clé, les caractères disparaissaient au fur et à mesure. Les appels
// concurrents attendent désormais la même promesse, et repartent tous avec la
// clé saisie une seule fois.
let _keyAsk = null;
function askKeyOnce(){
if(!_keyAsk){
_keyAsk = askPrompt('Clé de pilotage Loki requise :', {title:'Authentification', placeholder:'clé…'})
.then(k => {
_keyAsk = null;
if(k){ TOKEN = k.trim(); localStorage.setItem('loki.key', TOKEN); }
return k;
}, e => { _keyAsk = null; throw e; });
}
return _keyAsk;
}
async function jget(u){ const r=await jfetch(u); return r.json(); }
async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); }
async function loadStatus(){
const s=await jget('/api/status');
const el=document.getElementById('status-svc');
// Trois états : service coupé (err) · service actif mais modèle pas encore
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
// Pastille COURTE et honnête. Le détail (cause exacte) va dans #model-err
// dessous : la pastille ne doit pas affirmer « incompatible » quand l'échec
// peut être tout autre chose.
let cls='err', txt='arrêté';
if(s.active && s.health){ cls='ok'; txt='prêt'; }
else if(s.load_error){ cls='err'; txt='erreur'; }
else if(s.active){
cls='loading'; txt='chargement…';
// Pourcentage estimé (octets du modèle déjà lus) : voir « ça avance »
// plutôt qu'un « chargement… » figé qui ressemble à un plantage.
if(typeof s.load_pct==='number' && s.load_pct>=0) txt='chargement '+s.load_pct+' %';
}
el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+txt;
MODEL_READY = !!(s.active && s.health);
// Le bouton « Libérer la VRAM » suit l'état RÉEL du moteur (et pas un drapeau
// local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se
// lire ici aussi, sinon le bouton propose de libérer une carte déjà libre.
ENGINE_ACTIVE = !!s.active;
// Un moteur qui a PLANTÉ au chargement n'est pas « déchargé » : sans ce
// drapeau, syncSendBtn proposerait de recharger un modèle qui va replanter.
LOAD_ERROR = s.load_error || '';
paintVramBtn();
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
STATUS_SEEN = true;
if(typeof syncSendBtn==='function') syncSendBtn();
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
if(s.version){
document.getElementById('ver').textContent='v'+s.version;
}
// Avertissement de lancement (App Translocation macOS) : rare, mais il explique
// des symptomes tres deroutants, donc on l'affiche en permanence tant qu'il dure.
const wb=document.getElementById('app-warn');
if(wb){
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
else { wb.style.display='none'; }
}
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
const me=document.getElementById('model-err');
if(me){
if(s.load_error){ me.textContent='⚠ '+s.load_error; me.style.display=''; }
else { me.style.display='none'; }
}
}
// Journal du moteur — section repliable comme les autres. La pastille d'état
// reste un raccourci ; le chargement du journal est déclenché par l'événement
// `toggle` du <details>, qu'on l'ouvre par la pastille ou par son chevron.
function toggleSvcLog(){
const box=document.getElementById('svc-log-box');
if(!box) return;
// 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');
if(!el) return;
el.textContent='chargement du journal…';
try{
const r=await jget('/api/service/log?n=120');
el.textContent = (r && r.log && r.log.trim()) ? r.log : 'journal vide — le moteur n\'a encore rien écrit.';
el.scrollTop = el.scrollHeight;
}catch(e){ el.textContent='journal indisponible : '+e; }
}
async function checkUpdate(){
const b=document.getElementById('upd-check'), msg=document.getElementById('upd-msg');
b.disabled=true; msg.textContent='Vérification…';
try{
const r=await jget('/api/update');
// En conteneur, la mise à jour passe par l'image : on l'explique au lieu de
// proposer un remplacement de binaire impossible ici.
if(r.note){ msg.textContent=r.note; }
else if(r.error){ msg.textContent='Erreur : '+r.error; }
else if(r.available){
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
msg.appendChild(btn);
} else { msg.textContent='Loki est à jour ✓'; }
}catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false;
}
// Emplacements — affichés avec le journal du moteur : c'est le panneau qu'on
// ouvre quand on cherche à comprendre l'état de son installation.
async function showPaths(){
const el=document.getElementById('paths-msg');
if(!el) return;
el.textContent='…';
try{
const p=await jget('/api/paths');
const rows=[['Données',p.home],['Base (config, préférences, conversation)',p.database],['Modèles',p.models],['Presets',p.presets],['Mémoire',p.memory],['Fichiers créés par l\'IA',p.workspace],['Moteur llama.cpp',p.backends],['Programme',p.exe]];
el.innerHTML=rows.map(r=>'<div style="margin-bottom:4px">'+r[0]+'<br><code style="word-break:break-all">'+escHtml(r[1]||'')+'</code></div>').join('');
}catch(e){ el.textContent='Erreur'; }
}
async function applyUpdate(){
const msg=document.getElementById('upd-msg');
msg.textContent='Téléchargement et installation…';
try{
// Signal dédié : le timeout par défaut (30 s) coupe le téléchargement du
// binaire sur une connexion lente et fait croire à un échec alors que la
// mise à jour aboutit côté serveur.
const ac=new AbortController(); const t=setTimeout(()=>ac.abort(), 10*60*1000);
let r;
try{ r=await (await jfetch('/api/update/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',signal:ac.signal})).json(); }
finally{ clearTimeout(t); }
if(r.ok){
msg.innerHTML='✓ Installé en <b>v'+r.version+'</b>.<br>'+(r.restart||'');
// Redémarrage auto du service côté serveur : le flux va se couper puis
// reconnecter tout seul (connectStream boucle). On rafraîchit l'état après.
if(r.restarting){ toast('mise à jour appliquée — reconnexion…'); setTimeout(loadAll, 6000); }
}
else { msg.textContent='Échec : '+(r.error||'inconnu'); }
}catch(e){ msg.textContent='Erreur pendant la mise à jour (réessaie).'; }
}
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
let CTX_MAX=0, CTX_USED=0, MODEL_READY=false;
// STATUS_SEEN : /api/status a répondu au moins une fois. Avant ça (ou s'il ne
// répond pas), on ne verrouille RIEN — mieux vaut un envoi qui échoue qu'un chat
// bloqué par un état inconnu.
let STATUS_SEEN=false;
function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
function updateCtxMeter(){
if(!CTX_MAX) return;
const pct=Math.min(100, Math.round(CTX_USED*100/CTX_MAX));
const fill=document.getElementById('ctx-fill');
fill.style.width=pct+'%';
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
// Les chiffres SANS le mot « contexte » : la jauge juste en dessous dit déjà de
// quoi on parle, et le pied de carte est étroit. Le libellé complet reste en
// infobulle.
const ct=document.getElementById('ctx-text');
ct.textContent=CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
ct.title='contexte utilisé';
// Bouton de compaction MANUELLE : visible dès la moitié du contexte pour qu'on
// 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');
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));
});
}
// ─── Libérer la mémoire vidéo ───────────────────────────────────────────────
// Tant que le moteur tourne, le modèle occupe la VRAM : une autre application
// qui réclame la carte (un jeu, un encodage, un autre serveur d'inférence) ne
// trouve plus rien à prendre. Ce bouton l'en sort, et le MÊME bouton la
// reprend ensuite — le geste existait déjà (« arrêter » le service, au fond des
// réglages), mais son nom ne disait pas qu'il rendait la carte.
//
// ENGINE_ACTIVE vient de /api/status (relu toutes les 5 s), jamais d'un drapeau
// posé au clic : sinon un second onglet, ou un arrêt venu du terminal,
// afficherait un bouton qui ment.
let ENGINE_ACTIVE = true, VRAM_BUSY = false, LOAD_ERROR = '';
function paintVramBtn(){
const b = document.getElementById('vram-btn');
if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy
b.disabled = false;
b.classList.toggle('reload', !ENGINE_ACTIVE);
b.textContent = ENGINE_ACTIVE ? 'Libérer la VRAM' : 'Recharger le modèle';
b.title = ENGINE_ACTIVE
? 'décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine'
: 'relance le moteur : le modèle revient en mémoire vidéo';
}
function vramBtnClick(){ return ENGINE_ACTIVE ? vramUnload(false) : vramReload(); }
function vramBusy(on, label){
VRAM_BUSY = on;
const b = document.getElementById('vram-btn');
if(b){ b.disabled = on; if(on && label) b.textContent = label; }
if(!on) paintVramBtn();
}
// force=true : on a déjà demandé confirmation ET accepté de couper une réponse
// en cours d'écriture (le serveur refuse sans ça, voir handleVramUnload).
// Les deux gestes sont exclusifs : un second clic (le bouton des réglages
// pendant que celui du moniteur travaille) lancerait un « stop » au milieu d'un
// démarrage, ou l'inverse. VRAM_BUSY tient lieu de verrou.
async function vramUnload(force){
if(VRAM_BUSY) return;
if(!force){
const ok = await askConfirm(
'Décharger le modèle ? Le moteur s\'arrête et rend la mémoire vidéo — le chat reste indisponible jusqu\'au rechargement.',
{title:'Libérer la VRAM', okText:'Libérer'});
if(!ok) return;
}
vramBusy(true, 'libération…');
let r = null;
try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){}
// Le serveur dit l'état RÉEL après coup : le repeindre depuis l'ancien
// ENGINE_ACTIVE (relu seulement toutes les 5 s) afficherait « Libérer » sur
// un moteur déjà arrêté.
if(r && typeof r.active === 'boolean') ENGINE_ACTIVE = r.active;
vramBusy(false);
if(r && r.generating){
const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?',
{title:'Génération en cours', okText:'Décharger quand même'});
if(ok) return vramUnload(true);
return;
}
// La mesure d'abord : la dictée peut avoir rendu de la VRAM alors même que le
// moteur était déjà arrêté — annoncer « déjà arrêté » nierait ce qui vient
// d'être libéré. Sous quelques dizaines de Mo, c'est le bruit du bureau entre
// deux lectures, pas une libération : on ne l'annonce pas.
if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau'));
else if(r.gpu && r.freed_mb >= 64) toast('VRAM libérée : ' + fmtMo(r.freed_mb));
else if(!r.was_active) toast('le moteur était déjà arrêté');
else toast('modèle déchargé');
loadAll();
}
function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
async function vramReload(){
if(VRAM_BUSY) return;
vramBusy(true, 'démarrage…');
let r = null;
try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
if(r && r.ok) ENGINE_ACTIVE = true;
vramBusy(false);
if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; }
toast('modèle en cours de chargement…');
loadAll();
}
async function loadRam(){
const m=await jget('/api/ram');
const box=document.getElementById('ram-details');
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
// précompilé (prebuilt.in_use) ou compilé ici (in_use) — sinon c'est un fork perso.
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'&quot;')+'">'+String(v)+'</span></div>';
const rows=[];
if(c.BIN){
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
// toujours le chemin complet, quel que soit le libellé.
let v;
// 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));
});
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
if(m) rows.push(row('N-CPU-MOE', m[1]));
document.getElementById('cfg').innerHTML = rows.join('');
}
// --- Réserve de hauteur des blocs peuplés par le réseau ---------------------
// Ces blocs sont vides (ou réduits à « … ») tant que le serveur n'a pas répondu,
// puis prennent leur vraie taille : tout le menu sautait alors d'un cran, ce qui
// se voyait surtout sur les sections du bas (« Moteur »). On mémorise leur
// hauteur d'une session à l'autre et on la réserve au démarrage ; la réserve
// tombe dès que le vrai contenu est là (elle ne fait que TENIR la place, elle
// n'affiche jamais rien de faux).
const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list'];
function reserveHeights(){
HRESERVE.forEach(id=>{
try{
const h=parseInt(localStorage.getItem('loki-h-'+id)||'0',10);
const el=document.getElementById(id);
if(el && h>0) el.style.minHeight=h+'px';
}catch(e){}
});
}
function releaseHeights(){
HRESERVE.forEach(id=>{
const el=document.getElementById(id); if(!el) return;
el.style.minHeight='';
try{ localStorage.setItem('loki-h-'+id, String(el.offsetHeight||0)); }catch(e){}
});
}
document.addEventListener('DOMContentLoaded', reserveHeights);
// Numéro (1-based) du preset vers lequel on bascule, tant que le serveur n'a pas
// fini de redémarrer le service. Basculer prend plusieurs secondes : sans ça, la
// liste restait FIGÉE sur l'ancien actif et on ne savait pas si le clic avait pris.
// Voir switchTo() dans 07-models.js.
let pendingPreset = 0;
// Dernière sélection PEINTE. La liste est redessinée à chaque rafraîchissement
// (et il y en a beaucoup : sondage de bascule, loadAll…) ; sans ce repère, les
// animations d'entrée (barre qui glisse, puce qui apparaît) repartaient à chaque
// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la
// sélection CHANGE vraiment.
let paintedSel = '';
async function loadPresets(){
const p=await jget('/api/presets');
// Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente.
if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0;
const act = p.find(x=>x.active);
// Build via DOM (not string concat) so preset names can contain anything —
// spaces, accents, quotes, < > & — without breaking markup or handlers.
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; }
// Signature de la sélection : quel preset est actif, et vers lequel on bascule.
const sel = (act?act.id:'')+'|'+pendingPreset;
const moved = sel !== paintedSel; paintedSel = sel;
p.forEach((x,i)=>{
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name);
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
// est dessiné par la police du système — sur Windows il sortait plus petit et
// plus bas que sur macOS. Un disque CSS a la même taille et la même assiette
// partout.
// Puce réservée à l'actif POUR DE BON : pendant la bascule, seule la barre
// orange parle ; la puce apparaît quand la barre passe au blanc.
if(x.active){ const d=document.createElement('i'); d.className='preset-dot'; nm.appendChild(d); }
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
// Second row: quant tag + bench perf, so the title row stays full-width.
const meta=document.createElement('div'); meta.className='preset-meta';
if(x.quant){
const q=document.createElement('span'); q.className='qtag';
q.textContent=x.quant; q.title='quantization';
meta.appendChild(q);
}
if(x.reasoning){
const rt=document.createElement('span'); rt.className='rtag';
rt.title='raisonnement actif ('+x.reasoning+')';
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
rt.appendChild(document.createTextNode(x.reasoning));
meta.appendChild(rt);
}
if(x.bench){
const bt=document.createElement('span'); bt.className='btag';
bt.title='prefill / decode — dernier bench de ce preset';
bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s';
meta.appendChild(bt);
}
info.appendChild(nm);
if(meta.children.length) info.appendChild(meta);
const edit=document.createElement('button');
// Engrenage (et non plus un crayon) : la silhouette est ronde, donc elle se
// lit comme centrée dans son coin, là où le crayon penché tirait de travers.
edit.className='preset-edit'; edit.title='réglages du preset';
edit.innerHTML='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); };
row.appendChild(info); row.appendChild(edit);
cont.appendChild(row);
});
}
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
async function loadAgent(){
const s=await jget('/api/agent');
const on = s.enabled;
document.getElementById('agent-toggle').checked = on;
document.getElementById('compact-toggle').checked = (s.compact !== false);
setBadge('agent-badge', on, on?'actif':'inactif');
document.getElementById('brand').classList.toggle('agent', on);
setAgentGate(on);
if(s.mem_mode){ document.getElementById('mem-mode').value = s.mem_mode; renderMemModeDesc(s.mem_mode); }
memPages = (s.pages || s.skills || []).slice().sort((a,b)=>a.name.localeCompare(b.name));
memShown = MEM_PAGE;
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none';
renderMemList();
}
// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
// Repli/dépli de la liste des pages mémoire (fermée par défaut → gagne de la place).
function toggleMemPages(){
const body=document.getElementById('mem-pages-body');
const bar=document.getElementById('mem-pages-bar');
const open=body.hasAttribute('hidden');
if(open){ body.removeAttribute('hidden'); bar.classList.add('open'); }
else { body.setAttribute('hidden',''); bar.classList.remove('open'); }
}
// Liste mémoire scalable : recherche + rendu plafonné (les milliers de pages ne
// déroulent plus une barre géante). memShown grimpe par paliers via « voir plus ».
let memPages=[], memShown=0; const MEM_PAGE=50;
function renderMemList(){
const q=(document.getElementById('mem-search').value||'').trim().toLowerCase();
const list=document.getElementById('mem-list');
list.textContent='';
if(!memPages.length){ list.innerHTML='<div class="muted">(aucune page mémoire)</div>'; return; }
const matches = q ? memPages.filter(x=>(x.name+' '+(x.desc||'')).toLowerCase().includes(q)) : memPages;
if(!matches.length){ list.innerHTML='<div class="muted">(aucun résultat pour « '+q.replace(/[<>&]/g,'')+' »)</div>'; return; }
const shown = matches.slice(0, memShown);
shown.forEach(x=>{
const row=document.createElement('div'); row.className='preset'; row.style.fontSize='12px';
row.onclick=()=>openMem(x.name);
const span=document.createElement('span');
const b=document.createElement('b'); b.style.color='var(--text)'; b.textContent=x.name; span.appendChild(b);
if(x.desc){ const d=document.createElement('span'); d.className='muted'; d.textContent=' — '+x.desc; span.appendChild(d); }
const btn=document.createElement('button'); btn.textContent='edit'; btn.style.cssText='margin:0;padding:2px 8px;font-size:11px';
btn.onclick=e=>{ e.stopPropagation(); openMem(x.name); };
row.appendChild(span); row.appendChild(btn); list.appendChild(row);
});
if(matches.length > shown.length){
const more=document.createElement('div'); more.className='mem-more';
more.textContent='+ voir plus ('+(matches.length-shown.length)+' de plus)';
more.style.cursor='pointer';
more.onclick=()=>{ memShown+=MEM_PAGE; renderMemList(); };
list.appendChild(more);
}
}
// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire
const loadMem = loadAgent;
async function toggleAgent(){
const on=document.getElementById('agent-toggle').checked;
if(on && !await askConfirm("L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.", {title:'Activer le mode agent ?', okText:'Activer', danger:true})){ document.getElementById('agent-toggle').checked=false; return; }
await jpost('/api/agent/toggle',{on});
loadAgent();
}
async function toggleCompact(){
const on=document.getElementById('compact-toggle').checked;
await jpost('/api/agent/compact',{on});
}
// Mode mémoire (3 états) — indépendant du mode agent.
const MEM_DESC={
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
};
function renderMemModeDesc(m){ const d=document.getElementById('mem-mode-desc'); if(d) d.textContent=MEM_DESC[m]||''; }
async function setMemMode(){
const mode=document.getElementById('mem-mode').value;
const r=await jpost('/api/memory',{mode});
renderMemModeDesc(r.mode||mode);
}
// Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte.
let internetOn=false, webEngine='go';
function renderInternet(s){
internetOn = !!s.enabled;
webEngine = s.engine || 'go';
document.getElementById('internet-toggle').checked = internetOn;
const sel=document.getElementById('web-engine');
if(sel && document.activeElement!==sel) sel.value = webEngine;
// Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré.
const cf=document.getElementById('crawl-fields');
if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none';
if(document.activeElement !== document.getElementById('crawl-url'))
document.getElementById('crawl-url').value = s.url || '';
// La pastille ne redit PAS ce que l'interrupteur montre déjà (actif/inactif) :
// elle ne sert qu'à signaler l'anomalie — actif mais serveur injoignable, cas
// où les outils web ne sont pas proposés au modèle.
if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable');
else setBadge('internet-badge', null);
// Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée),
// on indique seulement si une clé est enregistrée.
const ks=document.getElementById('crawl-key-state'), kc=document.getElementById('crawl-key-clear');
if(ks){
ks.textContent = s.key_set ? ('clé enregistrée '+(s.key_hint||'')) : 'aucune clé — serveur ouvert';
if(kc) kc.style.display = s.key_set ? '' : 'none';
const ki=document.getElementById('crawl-key');
if(ki && document.activeElement!==ki) ki.value='';
if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)';
}
const st=document.getElementById('internet-status');
if(webEngine!=='crawl4ai'){ st.innerHTML='<span style="color:var(--accent)">✓</span> moteur intégré — aucune installation requise (pas de rendu JavaScript)'; }
else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
else if(s.reachable){ st.innerHTML='<span style="color:var(--accent)">✓</span> serveur joignable — outils web actifs'; }
else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; }
}
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
let OAI_KEY='', OAI_REVEAL=false;
async function copyText(txt, msg){
if(!txt){ toast('rien à copier'); return; }
try{ await navigator.clipboard.writeText(txt); }
catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
toast(msg||'copié');
}
function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); }
function renderApiKey(d){
OAI_KEY = d.key || '';
// 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é — 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
// /api/chat/state ; 0 = conversation vide, et la fenêtre n'a alors rien à régler.
let xTurnsTotal = 0;
async function openExportModal(){
showModal('export-modal');
try{
const st = await jget('/api/chat/state');
xTurnsTotal = (st && st.turns) || 0;
}catch(e){ xTurnsTotal = 0; }
// Rien à exporter : on remplace les réglages par un message, plutôt que de
// proposer de tailler un fichier qui serait vide de toute façon.
const vide = xTurnsTotal === 0;
document.getElementById('x-empty').style.display = vide ? '' : 'none';
document.getElementById('x-body').style.display = vide ? 'none' : '';
document.getElementById('x-go').style.display = vide ? 'none' : '';
if(vide) return;
const el = document.getElementById('x-turns');
el.max = String(xTurnsTotal);
el.value = el.max; // par défaut : tout le fil
el.disabled = xTurnsTotal < 2; // un seul échange : rien à trancher
onExportFormat();
}
function closeExportModal(){ hideModal('export-modal'); }
// Le format ne change QUE le contenant : les options de contenu sont les mêmes
// des deux côtés. Il n'y a donc plus rien à montrer ou cacher ici, seulement le
// résumé à rafraîchir.
function onExportFormat(){ onExportPreview(); }
function exportFormat(){
const r = document.querySelector('input[name=x-fmt]:checked');
return r ? r.value : 'md';
}
// Construit la requête d'export à partir des cases. On n'envoie QUE ce qui
// s'écarte du défaut : l'URL reste lisible, et un export complet redevient le
// simple /api/chat/export.
function exportQuery(){
const p = new URLSearchParams();
if(exportFormat() === 'json') p.set('format','json');
const on = id => document.getElementById(id).checked;
if(!on('x-reasoning')) p.set('reasoning','0');
if(!on('x-tools')) p.set('tools','0');
if(!on('x-results')) p.set('results','0');
const t = exportTurns();
if(t > 0) p.set('turns', String(t));
const q = p.toString();
return '/api/chat/export' + (q ? '?'+q : '');
}
// Valeur de portée à envoyer : 0 = tout le fil. La butée DROITE du curseur vaut
// « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se
// périmerait à l'échange suivant.
function exportTurns(){
const v = parseInt(document.getElementById('x-turns').value, 10) || 0;
if(!xTurnsTotal || v >= xTurnsTotal) return 0;
return v;
}
function onExportPreview(){
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
const tools = document.getElementById('x-tools');
const results = document.getElementById('x-results');
results.disabled = !tools.checked;
if(!tools.checked) results.checked = false;
paintExportRange();
const off = [];
if(!document.getElementById('x-reasoning').checked) off.push('raisonnements');
if(!tools.checked) off.push('outils');
else if(!results.checked) off.push('sorties d\'outils');
document.getElementById('x-note').textContent = off.length
? 'Export sans ' + off.join(' ni ') + '.'
: '';
}
// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque
// pixel de glissement, il ne doit RIEN demander au serveur.
function paintExportRange(){
const el = document.getElementById('x-turns');
const n = parseInt(el.value, 10) || 0;
const max = Math.max(1, xTurnsTotal);
// La pastille ne parcourt pas toute la largeur : elle va de THUMB/2 à
// largeur - THUMB/2. On coupe le remplissage dans SON repère, sinon la piste
// colorée déborde à côté d'elle aux extrémités (même calcul que la barre GPU).
const frac = max > 1 ? (n - 1) / (max - 1) : 1;
const fill = document.getElementById('x-fill');
if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)';
const scope = document.getElementById('x-scope');
if(!scope) return;
scope.textContent = n >= xTurnsTotal
? 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'
: n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal;
}
async function runExport(){
const btn = document.getElementById('x-go');
btn.disabled = true;
try{ await downloadExport(exportQuery()); closeExportModal(); }
finally{ btn.disabled = false; }
}
// On passe par jfetch (et pas par un simple <a href>) pour deux raisons : la clé
// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas,
// et le chemin de base change derrière le tunnel (/u/<id>).
async function downloadExport(url){
toast('préparation de l\'export…');
try{
const r = await jfetch(url);
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
// Derrière app.ajean.link, le proxy chiffré réemballe TOUTE réponse en JSON :
// un export Markdown revenait donc comme une chaîne JSON entre guillemets,
// échappements compris. On la déballe. En local, rien à faire — le type de
// contenu n'est pas du JSON, et un export JSON est déjà à sa place.
let text = await r.text();
if((r.headers.get('Content-Type')||'').includes('json')){
try{ const v = JSON.parse(text); if(typeof v === 'string') text = v; }catch(_){}
}
const blob = new Blob([text]);
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom
// déduit du format DEMANDÉ — le type renvoyé ne dit plus rien derrière le
// tunnel, où tout arrive en application/json.
const cd = r.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : 'loki-conversation.' + (url.includes('format=json') ? 'json' : 'md');
// ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le
// redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte
// du const — l'export échouerait avant même de partir.
const blobURL = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobURL; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
// dans la foulée du clic.
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
toast('exporté : ' + name);
}catch(e){ toast('erreur : ' + e.message); }
}
// 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;
if(action==='generate' && OAI_KEY && !await askConfirm('Générer une nouvelle clé invalide l\'ancienne (les clients devront être mis à jour). Le service va redémarrer.', {title:'Régénérer la clé ?', okText:'Générer'})) return;
OAI_REVEAL=(action==='generate'); // révèle la clé fraîche pour qu'on puisse la copier
toast('application…');
renderApiKey(await jpost('/api/apikey', {action}));
}
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;
OAI_REVEAL=true; toast('application…');
renderApiKey(await jpost('/api/apikey', {action:'set', key:k.trim()}));
}
async function toggleInternet(){
const on=document.getElementById('internet-toggle').checked;
const url=document.getElementById('crawl-url').value.trim();
// Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a
// besoin d'aucun réglage : on active directement.
if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
renderInternet(await jpost('/api/internet',{enabled:on, url}));
}
async function saveWebEngine(){
const engine=document.getElementById('web-engine').value;
renderInternet(await jpost('/api/internet',{engine}));
toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné');
}
async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
toast(url ? 'serveur Crawl4AI enregistré' : 'serveur Crawl4AI retiré');
}
async function saveCrawlKey(){
const el=document.getElementById('crawl-key'), key=el.value.trim();
if(!key){ toast('saisis une clé (ou « retirer » pour l\'enlever)'); return; }
renderInternet(await jpost('/api/internet',{key}));
toast('clé enregistrée');
}
async function clearCrawlKey(){
if(!await askConfirm('Retirer la clé du serveur Crawl4AI ?')) return;
document.getElementById('crawl-key').value='';
renderInternet(await jpost('/api/internet',{key:''}));
toast('clé retirée');
}
async function loadAll(){
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
// 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(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
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); }
function openBenchModal(){ showModal('bench-modal'); }
function closeBenchModal(){ hideModal('bench-modal'); }
async function runBenchUI(){
const btn = document.getElementById('btn-bench');
const rerun = document.getElementById('bench-rerun');
const body = document.getElementById('bench-body');
openBenchModal();
btn.disabled = true; btn.textContent = '⏳ bench…';
rerun.disabled = true;
body.innerHTML =
'<div style="text-align:center;padding:20px 0">' +
'<div style="font-size:24px;animation:spin 1s linear infinite;display:inline-block">⏳</div>' +
'<div class="muted" style="margin-top:8px">prompt 2000 tok + 300 decode<br>~10 secondes…</div>' +
'</div>';
try{
const r = await jget('/api/bench');
if(!r.ok){
body.innerHTML = '<div style="color:var(--err);text-align:center">erreur: '+r.error+'</div>';
return;
}
const x = r.result;
body.innerHTML =
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Prefill</div>' +
'<div style="font-size:26px;color:var(--accent);font-weight:600;margin:6px 0">'+x.prompt_per_second.toFixed(0)+'</div>' +
'<div class="muted">tok/s</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s</div>' +
'</div>' +
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Decode</div>' +
'<div style="font-size:26px;color:var(--ok);font-weight:600;margin:6px 0">'+x.predicted_per_second.toFixed(1)+'</div>' +
'<div class="muted">tok/s</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s</div>' +
'</div>' +
'</div>' +
'<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>';
} finally {
btn.disabled = false; btn.textContent = 'bench';
rerun.disabled = false;
loadPresets();
}
}
async function switchTo(n,name){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
toast('switching…');
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
pendingPreset = n; loadPresets();
const r=await jpost('/api/switch',{n:n});
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
toast('switched');
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
// l'écrit AVANT de répondre (et relance le service en arrière-plan, voir
// handleSwitch), donc un rafraîchissement immédiat suffit — la barre passe au
// blanc tout de suite. Le chargement du modèle par llama-server continue
// derrière ; il ne conditionne pas la sélection.
try{ await loadPresets(); }catch(e){}
// Filet : si l'empreinte n'a pas encore basculé (bascule lente côté disque), on
// reste en attente le temps qu'il faut, sans dépasser ~60 s.
for(let i=0; i<40 && pendingPreset===n; i++){
await new Promise(r=>setTimeout(r,1500));
try{ await loadPresets(); }catch(e){}
}
pendingPreset = 0;
loadAll();
}
// editingKey = the identifier of the item being edited: a preset id (filename)
// or a skill name. Empty string = creating a new item.
let editingKey = '', editingKind = 'preset';
const KINDS = {
// presets are keyed by `id` (filename) so several can share a display name;
// skills keep name-as-identity (param 'name').
// newLabel / nameHint : « Nouveau Page » et « Nom du preset » dans l'éditeur de
// mémoire venaient d'un libellé unique décliné mécaniquement.
preset: {label:'', newLabel:'Nouveau preset', nameHint:'Nom du preset',
param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()},
mem: {label:'Page', newLabel:'Nouvelle page', nameHint:'Nom de la page',
param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()},
};
// ⚠️ La modale s'ouvre AVANT d'aller chercher quoi que ce soit. Elle attendait
// auparavant la fin de 3 requêtes (contenu + backends + modèles disponibles) :
// en accès distant, ça faisait un clic sans réaction pendant une seconde, comme
// si le bouton était mort. On affiche la coquille tout de suite, puis on la
// remplit. `openSeq` protège du cas « deux ouvertures coup sur coup » : une
// réponse en retard ne doit jamais écraser la modale ouverte après elle.
// Pendant ce remplissage la modale est en `.loading` : un rond tourne à la place
// du formulaire, qui n'est révélé qu'une fois TOUT en place (contenu, réglages,
// backends, GPU) — sinon on voyait les champs se peupler un par un.
let openSeq = 0;
async function openItem(kind, key){
const K = KINDS[kind];
const seq = ++openSeq;
editingKind = kind; editingKey = key || '';
document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + key : key) : K.newLabel;
document.getElementById('m-name').value = key || '';
document.getElementById('m-name').placeholder = K.nameHint;
document.getElementById('m-content').value = '';
document.getElementById('m-del').style.display = key ? 'inline-flex' : 'none';
// Model picker is preset-only: it edits the MODEL= line of the preset.
const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row');
const samplingRow = document.getElementById('m-sampling-row');
const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body');
const rawCaret = document.getElementById('m-raw-caret');
if(kind === 'preset'){
modelRow.style.display = 'flex';
settingsRow.style.display = 'flex';
if(samplingRow) samplingRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
resetDlUI();
resetHfUI();
// Preset : la config brute est une ligne repliable, fermée par défaut.
rawHead.textContent = 'Configuration';
rawToggle.style.display = '';
rawBody.style.display = 'none';
rawCaret.classList.remove('open');
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false);
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
setHFTokenOpen(false);
loadHFToken();
} else {
modelRow.style.display = 'none';
settingsRow.style.display = 'none';
if(samplingRow) samplingRow.style.display = 'none';
// Page mémoire : le contenu EST le champ principal — affiché en clair.
rawHead.textContent = 'Contenu';
rawToggle.style.display = 'none';
rawBody.style.display = '';
}
const modalEl=document.getElementById('modal');
modalEl.classList.add('loading');
showModal('modal');
// Le voile s'arrête au bas de l'en-tête : on lui passe sa hauteur réelle. ⚠️ À
// mesurer APRÈS l'affichage — un élément en display:none mesure 0.
const mHead=modalEl.querySelector('.modal-head');
if(mHead) modalEl.querySelector('.modal-card').style.setProperty('--mh', mHead.offsetHeight+'px');
// ⚠️ APRÈS showModal, jamais avant : le corps de la modale est un conteneur
// défilant RÉUTILISÉ d'une ouverture à l'autre, et écrire scrollTop sur un
// élément en display:none ne fait RIEN (il n'a pas de boîte de rendu) — le
// navigateur restaurait donc l'ancienne position à l'affichage, et on rouvrait
// la fiche au milieu. On le refait après le remplissage, la hauteur ayant changé.
const topPeBody=()=>{ const b=document.querySelector('#modal .pe-body'); if(b) b.scrollTop=0; };
topPeBody();
// --- Remplissage, une fois la modale à l'écran -----------------------------
const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||''));
const d = await r.json();
if(seq !== openSeq) return; // une autre ouverture a pris la main
const display = d.name || key || '';
document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + display : display) : K.newLabel;
document.getElementById('m-name').value = display;
document.getElementById('m-content').value = d.content || '';
if(kind === 'preset'){
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ?
await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
if(seq !== openSeq) return;
}
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
// les transitions deux frames plus tard, une fois le nouvel état peint.
modalEl.classList.add('no-anim');
modalEl.classList.remove('loading');
topPeBody();
requestAnimationFrame(()=>requestAnimationFrame(()=>modalEl.classList.remove('no-anim')));
}
// Pretty-print bytes — handy for the dropdown options.
function fmtSize(b){
if(b > 1e9) return (b/1e9).toFixed(1)+' GB';
if(b > 1e6) return (b/1e6).toFixed(0)+' MB';
if(b > 1e3) return (b/1e3).toFixed(0)+' KB';
return b+' B';
}
// Retire les guillemets ENTOURANTS d'une valeur, et seulement eux. Les anciens
// motifs `"?([^"\n]*)"?` s'arrêtaient au premier guillemet INTERNE : une valeur
// comme `--chat-template-file "/etc/loki/tpl.jinja"` était lue tronquée, puis
// réécrite avec des guillemets non appariés (issue #17).
function unquoteVal(v){
v = String(v==null?'':v).trim();
const q = v[0];
if(v.length >= 2 && (q === '"' || q === "'") && v[v.length-1] === q) return v.slice(1,-1);
return v;
}
// Lit la valeur d'une clé KEY= dans un texte au format .env.
function readEnvKey(txt, key){
const m = String(txt).match(new RegExp('^[ \\t]*'+key+'[ \\t]*=(.*)$','m'));
return m ? unquoteVal(m[1]) : '';
}
// Read the current MODEL= value out of the textarea, handling quotes / spaces.
function currentModelInTextarea(){
return readEnvKey(document.getElementById('m-content').value, 'MODEL');
}
// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers :
// on ne contrôle pas ce que l'utilisateur y met).
function escHtml(s){
return String(s==null?'':s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Compare deux chemins/noms de modèle en neutralisant séparateurs et casse.
function samePath(a, b){
const n = x => String(x||'').trim().replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
return !!a && !!b && n(a) === n(b);
}
function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); }
async function populateModelPicker(){
const sel = document.getElementById('m-model');
const list = await jget('/api/models');
const cur = currentModelInTextarea();
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre).
const groups = [];
for(const m of (list||[])){
let g = groups.find(x => x.dir === m.dir);
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
g.items.push(m);
}
let html = '<option value="">— choisir un modèle —</option>';
let matched = false;
for(const g of groups){
let opts = '';
for(const m of g.items){
// Le preset peut référencer le chemin complet OU le simple nom de fichier.
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
if(on) matched = true;
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
// (les autres ne se lancent pas seules) et la taille de la famille entière.
// On le dit, et surtout on signale les tranches manquantes — sinon le
// modèle se sélectionne sans broncher puis le moteur meurt au démarrage.
let tag = '';
if(m.shards > 1) tag = ' · ' + m.shards + ' fichiers';
if(m.missing && m.missing.length) tag += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+tag+')</option>';
}
html += groups.length > 1
? '<optgroup label="'+escHtml(g.home ? 'dossier loki' : g.dir)+'">'+opts+'</optgroup>'
: opts;
}
// MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré :
// on déplie alors la section des dossiers, c'est là que ça se répare.
if(cur && !matched){
html += '<option value="" disabled selected>('+escHtml(cur)+' — introuvable ; ajoute son dossier ci-dessous)</option>';
}
sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true);
}
// ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){
const b = document.getElementById('m-dirs-body');
const c = document.getElementById('m-dirs-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
if(open) populateModelDirs();
}
function toggleModelDirs(){
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
}
// ---- Jeton Hugging Face ----------------------------------------------------
// Un dépôt « gated » se lit sans jeton mais ne se télécharge pas. Le jeton se
// posait uniquement par la variable d'environnement HF_TOKEN : en conteneur, ça
// voulait dire éditer un docker-compose et recréer le conteneur pour répondre à
// un 401 découvert ici. Il se règle donc à l'endroit où le problème apparaît.
function setHFTokenOpen(open){
const b = document.getElementById('m-hft-body');
const c = document.getElementById('m-hft-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
}
function toggleHFToken(){
setHFTokenOpen(document.getElementById('m-hft-body').style.display === 'none');
}
async function loadHFToken(){
let d = {};
try{ d = await jget('/api/hf/token'); }catch(_){ return; }
renderHFToken(d);
}
// Le jeton n'est jamais renvoyé en clair par le serveur : on n'affiche que sa
// forme masquée et sa provenance. « env » se signale à part — l'interface ne
// peut pas effacer une variable du conteneur, promettre le contraire par un
// bouton « Retirer » serait mentir.
function renderHFToken(d){
const st = document.getElementById('m-hft-state');
const note = document.getElementById('m-hft-note');
const clear = document.getElementById('m-hft-clear');
const inp = document.getElementById('m-hft-input');
if(!st || !note || !clear || !inp) return;
inp.value = '';
const src = (d && d.source) || '';
st.textContent = src ? (d.masked || 'enregistré') : 'aucun';
clear.style.display = src === 'config' ? '' : 'none';
if(src === 'env'){
note.textContent = 'Jeton fourni par la variable d’environnement HF_TOKEN. En enregistrer un ici le remplacera.';
} else if(src === 'config'){
note.textContent = 'Jeton enregistré' + (d.account ? ' — compte ' + d.account : '')
+ '. Un dépôt à accès restreint exige EN PLUS d’avoir accepté ses conditions sur huggingface.co avec ce compte.';
} else {
note.textContent = 'Sans jeton, les dépôts à accès restreint répondent 401 au téléchargement. Crée un jeton en lecture sur huggingface.co/settings/tokens, accepte les conditions du dépôt, puis colle-le ici.';
}
}
// Vérifié côté serveur AVANT d'être enregistré : un jeton mal collé accepté en
// silence rendrait exactement le 401 qu'on cherche à expliquer.
async function saveHFToken(){
const inp = document.getElementById('m-hft-input');
const tok = (inp.value || '').trim();
if(!tok){ toast('colle un jeton (hf_…)'); return; }
toast('vérification du jeton…');
const r = await jpost('/api/hf/token', {token: tok});
if(!r || !r.ok){ toast('jeton refusé : ' + ((r && r.error) || '')); return; }
renderHFToken(r);
toast(r.account ? 'jeton enregistré — compte ' + r.account : 'jeton enregistré');
}
async function clearHFToken(){
if(!await askConfirm('Les dépôts à accès restreint répondront de nouveau 401 au téléchargement.',
{title:'Retirer le jeton Hugging Face ?', okText:'Retirer'})) return;
const r = await jpost('/api/hf/token', {token: ''});
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || '')); return; }
renderHFToken(r);
toast('jeton retiré');
}
async function populateModelDirs(){
const box = document.getElementById('m-dirs-list');
if(!box) return;
let d = {};
try{ d = await jget('/api/models/dirs'); }catch(_){ return; }
// Construit en DOM (pas en HTML concaténé) : les chemins viennent de
// l'utilisateur et se retrouveraient sinon dans un attribut onclick.
box.innerHTML = '';
for(const x of (d.dirs||[])){
const row = document.createElement('div');
const p = document.createElement('span');
p.textContent = x.path + ' ';
const info = document.createElement('span');
info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
// « ~ » quand le système de fichiers approxime (FUSE/shfs d'Unraid,
// overlay, réseau) : le chiffre est indicatif, il ne bloque plus rien.
const free = x.free >= 0 ? ', ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier loki' : '') + ')';
row.append(p, info);
if(!x.home){
const del = document.createElement('button');
del.className = 'pe-link';
del.textContent = 'retirer';
del.style.marginLeft = '8px';
del.onclick = () => removeModelDir(x.path);
row.append(del);
}
box.append(row);
}
}
async function addModelDir(){
const inp = document.getElementById('m-dir-path');
const p = inp.value.trim();
if(!p){ toast('indique un chemin de dossier'); return; }
const r = await jpost('/api/models/dirs', {path: p, action: 'add'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
inp.value = '';
toast('dossier ajouté');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
}
async function removeModelDir(p){
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('dossier retiré');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
}
function onPickModel(){
const val = document.getElementById('m-model').value;
if(!val) return;
const ta = document.getElementById('m-content');
if(/^\s*MODEL\s*=.*$/m.test(ta.value)){
ta.value = ta.value.replace(/^\s*MODEL\s*=.*$/m, 'MODEL="'+val+'"');
} else {
// No MODEL= line yet — prepend so it's visible at the top.
ta.value = 'MODEL="'+val+'"\n' + ta.value;
}
toast('MODEL='+val);
}
// --- Vision (projecteur multimodal --mmproj) --------------------------------
// Le projecteur est un .gguf séparé du modèle : il apparaît donc dans la même
// liste que les modèles (/api/models). On le range dans sa PROPRE clé MMPROJ,
// que backend_serve.go traduit en --mmproj — plutôt que dans EXTRA_ARGS, pour
// que le chemin soit résolu comme celui du modèle (nom simple = cherché dans
// les dossiers déclarés).
function currentMmprojInTextarea(){
// Clé dédiée d'abord ; à défaut, un --mmproj posé à la main dans EXTRA_ARGS
// (anciens presets), pour que le champ montre la vision déjà configurée.
return readEnvKey(document.getElementById('m-content').value, 'MMPROJ')
|| eaGetValued('--mmproj');
}
function onPickMmproj(){
// On retire un éventuel --mmproj d'EXTRA_ARGS pour ne pas le charger deux fois,
// et on centralise le choix dans la clé MMPROJ.
eaSetValued('--mmproj', '');
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
}
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
// mêler les modèles de plusieurs Go n'aiderait pas à choisir des yeux.
function isMmprojName(name){ return /mmproj/i.test(String(name||'')); }
async function populateMmproj(){
const sel = document.getElementById('m-mmproj');
if(!sel) return;
const list = await jget('/api/models');
const cur = currentMmprojInTextarea();
const items = (list||[]).filter(m => isMmprojName(m.name));
let html = '<option value="">— aucune —</option>';
let matched = false;
for(const m of items){
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
samePath(baseName(cur), m.name) ? ' selected' : '';
if(on) matched = true;
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
// MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors
// convention) : on le garde affiché plutôt que de l'effacer silencieusement.
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
}
sel.innerHTML = html;
}
// Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé)
// qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel
// backend fait tourner ce modèle — la barre latérale ne fait qu'installer.
function currentBinInTextarea(){
return readEnvKey(document.getElementById('m-content').value, 'BIN');
}
// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows).
function sameBinPath(a, b){
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
return !!a && !!b && n(a) === n(b);
}
// Écrit (ou remplace) la ligne BIN= dans le contenu du preset.
function setBinInTextarea(val){
const ta = document.getElementById('m-content');
if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"');
else ta.value = 'BIN="'+val+'"\n' + ta.value;
}
// Le moteur précompilé s'installe dans un dossier versionné (…/llama-b10280/) :
// le BIN d'un preset écrit avant une mise à jour ne vaut plus le chemin courant,
// alors qu'il désigne bien ce moteur. On reconnaît donc l'appartenance au
// dossier, sinon tous les presets retombaient en « personnalisé » à chaque MAJ.
function underDir(p, dir){
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
return !!p && !!dir && n(p).startsWith(n(dir)+'/');
}
let beFastPath = '', beOptPath = '', beFastDir = '', beImagePath = '';
async function populateBackend(){
// Chemins des deux moteurs gérés + liste des backends détectés (dossier loki).
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
beOptPath = lc.bin || '';
// Conteneur : le moteur vient de l'image. « Précompilé » et « Compilé »
// désignent des installations que Loki ne fait pas ici — les proposer ne
// menait qu'à « installez d'abord… ». On les remplace par « Image ».
beImagePath = lc.provided ? (lc.provided_bin || '') : '';
document.getElementById('be-lab-image').style.display = beImagePath ? '' : 'none';
document.getElementById('be-lab-fast').style.display = beImagePath ? 'none' : '';
document.getElementById('be-lab-opt').style.display = beImagePath ? 'none' : '';
// En conteneur, « Personnalisé » ne mène nulle part (rien à compiler, et le
// moteur se met à jour par la carte Moteur des réglages) : on le retire.
document.getElementById('be-lab-custom').style.display = beImagePath ? 'none' : '';
document.getElementById('be-image-note').textContent = beImagePath ? 'llama.cpp officiel' : '';
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
// le dossier backends de loki (l'utilisateur peut y déposer son propre build).
const detected = await jget('/api/backends');
const sel = document.getElementById('m-backend-detected');
let html = '<option value="">— ou choisir un backend détecté —</option>';
for(const b of (detected||[])) html += '<option value="'+b.path+'">'+b.name+'</option>';
sel.innerHTML = html;
document.getElementById('be-drop-hint').textContent = lc.backends_dir
? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
// Sélectionne l'option correspondant au BIN actuel du preset.
const cur = currentBinInTextarea();
let mode = 'custom';
// Un preset sans BIN hérite du moteur global : en conteneur c'est celui de
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ». Et comme
// l'option Personnalisé n'y est plus proposée, TOUT BIN non reconnu retombe
// aussi sur l'image (un vieux preset pointant un chemin mort redevient sain).
if(beImagePath && (sameBinPath(cur, beImagePath) || !cur)) mode = 'image';
else if(beImagePath) mode = 'image';
else if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
const radio = document.querySelector('input[name=m-be][value='+mode+']');
if(radio) radio.checked = true;
toggleBackendCustom(mode);
if(mode === 'custom') document.getElementById('m-backend-path').value = cur;
// Attendu (et non lancé dans le vide) : la liste des GPU change la hauteur du
// bloc « moteur ». Sans ce await, elle arrivait APRÈS la levée du voile de
// chargement et on voyait le bloc bouger tout seul.
await loadGpuDevices();
}
function toggleBackendCustom(mode){
document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none';
}
function onBackendMode(mode){
toggleBackendCustom(mode);
if(mode === 'image'){
if(!beImagePath){ toast('aucun moteur fourni par l\'image'); return; }
setBinInTextarea(beImagePath); toast('moteur : llama.cpp de l\'image');
loadGpuDevices();
} else if(mode === 'fast'){
if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; }
setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé');
loadGpuDevices();
} else if(mode === 'opt'){
if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; }
setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé');
loadGpuDevices();
}
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
}
function onCustomPath(){
const v = document.getElementById('m-backend-path').value.trim();
if(v){ setBinInTextarea(v); loadGpuDevices(); }
}
function onPickDetected(){
const v = document.getElementById('m-backend-detected').value;
if(!v) return;
document.getElementById('m-backend-path').value = v;
setBinInTextarea(v); toast('moteur personnalisé');
loadGpuDevices();
}
// --- Cartes graphiques (--device / --tensor-split) --------------------------
// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement :
// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que
// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles
// qui s'étalaient sur les deux cartes malgré une sélection « GPU 1 ».
// La lecture/écriture des flags passe par eaGetValued/eaSetValued (le tokenizer
// d'EXTRA_ARGS déjà utilisé par les autres réglages) : un seul parseur.
// Diamètre de la pastille du curseur, en pixels — DOIT rester synchronisé avec
// .gpu-range::-webkit-slider-thumb / ::-moz-range-thumb dans styles.css.
const GPU_THUMB = 16;
let gpuDevices = [];
const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture
// Interroge le moteur du preset (--list-devices) : les noms de device et leur
// ordre lui appartiennent, une liste issue de nvidia-smi désignerait la mauvaise
// carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la réponse n'est
// pas là, et le reste s'il y a moins de deux cartes : rien à arbitrer, et ça
// évite un encart qui surgit après coup.
// Remplit le cache sans rien peindre (appelé au chargement de la page pour le
// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt.
async function prefetchGpuDevices(bin){
if(!bin || gpuCache[bin]) return;
try{
const r = await jpost('/api/backends/devices', {bin});
if(r && r.ok) gpuCache[bin] = r.devices || [];
}catch(_){}
}
async function loadGpuDevices(){
const group = document.getElementById('m-gpu-group');
const bin = currentBinInTextarea();
if(!bin){ group.hidden = true; gpuDevices = []; return; }
if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; }
let r = {};
try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; }
// Le moteur a pu changer pendant l'appel (clic rapide) : on ne peint que si la
// réponse concerne toujours le moteur affiché.
if(currentBinInTextarea() !== bin) return;
gpuDevices = (r.ok && r.devices) ? r.devices : [];
if(r.ok) gpuCache[bin] = gpuDevices;
renderGpu();
}
function selectedGpuIds(){
const sel = eaGetValued('--device').split(',').filter(Boolean);
const known = gpuDevices.map(d => d.id);
const kept = sel.filter(id => known.includes(id));
return kept.length ? kept : known; // aucune contrainte = toutes les cartes
}
function gpuEsc(t){
return String(t).replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
}
function renderGpu(){
const group = document.getElementById('m-gpu-group');
// Une seule carte (ou aucune) : rien à répartir, rien à choisir.
if(gpuDevices.length < 2){ group.hidden = true; return; }
const sel = selectedGpuIds();
let html = gpuDevices.map(d => {
// Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le
// modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge.
const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : '';
return '<div class="pe-row">'
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
+ '<span class="pe-sub">' + gpuEsc(d.id) + gb + '</span></span>'
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
+ '</div>';
}).join('');
if(sel.length > 1) html += splitHtml(sel);
document.getElementById('m-gpu-list').innerHTML = html;
document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length
? 'Toutes les cartes sont utilisées.'
: 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.';
group.hidden = false;
if(sel.length > 1) paintSplit();
}
function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; }
// Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1
// ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui
// veulent savoir ce qui part dans --device.
function gpuLabel(d){
return String((d && d.name) || (d && d.id) || '')
.replace(/^NVIDIA\s+GeForce\s+/i, '')
.replace(/^NVIDIA\s+/i, '')
.replace(/^AMD\s+(Radeon\s+)?/i, '')
.replace(/^Intel\(R\)\s+/i, '')
.trim() || (d && d.id) || '';
}
// Répartition : UNE seule barre, sur laquelle on glisse directement (le curseur
// est transparent, posé par-dessus). Trois cartes ou plus : un pourcentage par
// carte. On n'écrit --tensor-split QUE si l'utilisateur y touche : sans lui,
// llama.cpp répartit tout seul.
function splitHtml(sel){
const shares = currentShares(sel);
let inner;
if(sel.length === 2){
inner = '<div class="gpu-split">'
+ '<div class="gpu-bar" id="m-split-bar"></div>'
+ '<input type="range" class="gpu-range" id="m-split-range" min="0" max="100" step="0.5"'
+ ' value="' + (shares[0]*100).toFixed(1) + '" oninput="onSplitRange()"'
+ ' aria-label="part de ' + gpuEsc(gpuLabel(gpuById(sel[0]))) + '"></div>';
} else {
inner = '<div class="gpu-bar" id="m-split-bar"></div>'
+ '<div class="gpu-shares">' + sel.map((id, i) =>
'<label class="gpu-share"><span>' + gpuEsc(gpuLabel(gpuById(id))) + '</span>'
+ '<input type="number" min="0" max="100" step="0.5" value="' + (shares[i]*100).toFixed(1) + '"'
+ ' oninput="onSplitNumbers()"><span class="pe-unit">%</span></label>').join('') + '</div>';
}
return '<div class="pe-row">'
+ '<span class="pe-row-l">Répartition<span class="pe-sub">part des couches par carte</span></span>'
+ '<span class="pe-row-c" id="m-split-state"></span></div>'
+ '<div class="pe-row stack">' + inner
+ '<div class="gpu-legend" id="m-split-legend"></div></div>';
}
// Parts actuelles, normalisées à 1. Sans --tensor-split : proportionnelles à la
// VRAM de chaque carte, ce que llama.cpp fait de son côté — la barre montre donc
// ce qui se passe réellement plutôt qu'un partage égal trompeur.
function currentShares(sel){
const raw = eaGetValued('--tensor-split').split(',').map(parseFloat).filter(n => !isNaN(n));
let vals = (raw.length === sel.length) ? raw
: sel.map(id => gpuById(id).total_mib || 0);
// Mémoires inconnues (lecture à 0) : à défaut de proportion crédible, on
// montre un partage égal plutôt qu'une barre pleine sur une seule carte.
if(vals.every(v => !v)) vals = sel.map(() => 1);
const sum = vals.reduce((a, b) => a + b, 0) || 1;
return vals.map(v => v/sum);
}
// Repeint UNIQUEMENT la barre, la légende et l'état. Surtout PAS le bloc entier :
// remplacer le HTML pendant un glissement détruit le curseur en cours d'usage —
// c'est ce qui faisait qu'il ne répondait qu'au clic.
function paintSplit(){
const sel = selectedGpuIds();
if(sel.length < 2) return;
const shares = currentShares(sel);
const bar = document.getElementById('m-split-bar');
// La pastille du curseur ne parcourt PAS toute la largeur : elle va de
// GPU_THUMB/2 à largeur - GPU_THUMB/2, sinon elle déborderait de la piste. Une
// coupure à « s % » pile tombait donc à côté d'elle (jusqu'à ~5 px d'écart aux
// extrêmes), ce qui laissait voir la piste claire juste à droite de la
// pastille. On coupe dans le repère de la pastille : s*(largeur - THUMB) + THUMB/2.
// Le dernier segment prend le reste (flex:1) : aucun filet résiduel à droite,
// quels que soient les arrondis.
const withThumb = sel.length === 2 && !!document.getElementById('m-split-range');
if(bar) bar.innerHTML = shares.map((s, i) => {
const cls = i%2 ? 'alt' : '';
if(i === shares.length - 1) return '<span class="' + cls + '" style="flex:1"></span>';
const w = withThumb
? 'calc(' + (s*100).toFixed(3) + '% - ' + ((s - 0.5) * GPU_THUMB).toFixed(2) + 'px)'
: (s*100).toFixed(3) + '%';
return '<span class="' + cls + '" style="width:' + w + '"></span>';
}).join('');
const leg = document.getElementById('m-split-legend');
if(leg) leg.innerHTML = sel.map((id, i) =>
'<span><i class="' + (i%2 ? 'alt' : '') + '"></i>' + gpuEsc(gpuLabel(gpuById(id)))
+ ' ' + (shares[i]*100).toFixed(1).replace('.', ',') + ' %</span>').join('');
const st = document.getElementById('m-split-state');
if(st) st.innerHTML = eaGetValued('--tensor-split')
? '<button class="pe-link" onclick="resetSplit()">réinitialiser</button>'
: '<span class="pe-unit">automatique</span>';
}
function writeSplit(shares){
const sum = shares.reduce((a, b) => a + b, 0) || 1;
eaSetValued('--tensor-split', shares.map(s => (s/sum).toFixed(3)).join(','));
paintSplit();
}
function onSplitRange(){
const v = parseFloat(document.getElementById('m-split-range').value) / 100;
writeSplit([v, 1-v]);
}
function onSplitNumbers(){
const vals = [...document.querySelectorAll('.gpu-share input')].map(i => parseFloat(i.value) || 0);
if(vals.reduce((a, b) => a + b, 0) <= 0) return; // saisie en cours
writeSplit(vals);
}
function resetSplit(){
eaSetValued('--tensor-split', '');
const sel = selectedGpuIds();
const r = document.getElementById('m-split-range');
if(r && sel.length === 2) r.value = (currentShares(sel)[0]*100).toFixed(1);
const nums = [...document.querySelectorAll('.gpu-share input')];
if(nums.length){
const sh = currentShares(sel);
nums.forEach((inp, i) => { inp.value = (sh[i]*100).toFixed(1); });
}
paintSplit();
}
function onGpuPick(){
const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value);
if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; }
// Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer
// des noms de device qui changeraient avec le moteur.
eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(','));
// Une répartition écrite pour N cartes n'a plus de sens pour N-1.
if(ids.length < 2) eaSetValued('--tensor-split', '');
renderGpu();
}
// Read/write the QUANT= override line in the preset textarea.
function currentQuantInTextarea(){
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=(.*)$/mi);
return m ? unquoteVal(m[1]) : '';
}
function applyQuant(){
const val = document.getElementById('m-quant').value.trim();
const ta = document.getElementById('m-content');
const re = /^\s*#?\s*QUANT\s*=.*$/mi;
if(val){
if(re.test(ta.value)) ta.value = ta.value.replace(re, 'QUANT="'+val+'"');
else ta.value = ta.value.replace(/\s*$/,'') + '\nQUANT="'+val+'"\n';
} else {
ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n');
}
}
// ---- Réglages simples : champs de formulaire <-> lignes du fichier .env -----
// Le fichier de config (textarea m-content) reste la source de vérité : chaque
// champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT.
function cfgReadKey(key){
return readEnvKey(document.getElementById('m-content').value, key);
}
function cfgWriteKey(key, val){
const ta = document.getElementById('m-content');
val = String(val==null?'':val).trim();
const reLine = new RegExp('^[ \\t]*'+key+'[ \\t]*=.*$','m');
if(val === ''){ // clé vidée → on retire la ligne
ta.value = ta.value.replace(new RegExp('^[ \\t]*'+key+'[ \\t]*=.*\\n?','m'),'').replace(/\n{3,}/g,'\n\n');
return;
}
const line = key+'='+(/\s/.test(val) ? '"'+val+'"' : val);
if(reLine.test(ta.value)) ta.value = ta.value.replace(reLine, line);
else ta.value = ta.value.replace(/\s*$/,'') + '\n'+line+'\n';
}
// EXTRA_ARGS : liste de drapeaux passés tels quels à llama-server. On les édite
// jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans
// toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute.
function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); }
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); }
function eaHasFlag(flag){ return eaTokens().includes(flag); }
function eaToggleFlag(flag, on){
const t = eaTokens().filter(x=>x!==flag);
if(on) t.push(flag);
eaSetTokens(t);
}
function eaGetValued(flag){
const t = eaTokens(), i = t.indexOf(flag);
return (i>=0 && i+1<t.length && !t[i+1].startsWith('-')) ? t[i+1] : '';
}
function eaSetValued(flag, val){
const t = eaTokens(), i = t.indexOf(flag);
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
val = String(val||'').trim();
if(val !== '') t.push(flag, val);
eaSetTokens(t);
}
// Remplit les champs de réglage depuis le contenu courant du preset.
function populateSettings(){
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
set('s-ctx', cfgReadKey('CTX'));
set('s-ngl', cfgReadKey('NGL'));
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
// Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas
// besoin de redémarrer le moteur pour qu'un changement prenne effet.
set('s-temp', cfgReadKey('TEMP'));
set('s-topp', cfgReadKey('TOP_P'));
set('s-topk', cfgReadKey('TOP_K'));
set('s-minp', cfgReadKey('MIN_P'));
set('s-presp', cfgReadKey('PRESENCE_PENALTY'));
set('s-reppen', cfgReadKey('REPEAT_PENALTY'));
set('s-moe', eaGetValued('--n-cpu-moe'));
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
// Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur.
// « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle,
// laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement
// réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le
// sous-titre la dit. (Les presets créés ou modifiés depuis cette version
// écrivent toujours on ou off, la question ne se pose plus pour eux.)
const rz = cfgReadKey('REASONING');
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz));
const rzSub = document.getElementById('s-reasoning-sub');
if(rzSub){
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){
// Deux emplacements, deux portées : ici c'est le défaut ENREGISTRÉ avec le
// preset (donc rattaché au modèle) ; la liste de la barre de saisie le
// dévie pour la suite de la session, jusqu'au prochain preset appliqué.
effSub.textContent = eff.value
? 'défaut de ce preset — sans effet si le gabarit du modèle l\'ignore'
: 'défaut de ce preset — ajustable à la volée depuis la barre de saisie';
}
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));
}
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
// la perdre.
function setSpecType(v){
const sel = document.getElementById('s-spec');
if(!sel) return;
v = String(v || '').trim();
if(v && ![...sel.options].some(o => o.value === v)){
const opt = document.createElement('option');
opt.value = v; opt.textContent = v + ' (dans la configuration)';
sel.appendChild(opt);
}
sel.value = v;
syncSpecRow();
}
// Le nombre de jetons anticipés ne veut rien dire sans type : ligne masquée, et
// flag retiré pour ne pas laisser un réglage orphelin dans le preset.
function syncSpecRow(){
const sel = document.getElementById('s-spec');
const row = document.getElementById('s-spec-n-row');
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
eaSetValued('--spec-type', v);
if(!v){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
syncSpecRow();
}
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
function toggleRaw(){
const b = document.getElementById('m-raw-body');
const c = document.getElementById('m-raw-caret');
const show = b.style.display === 'none';
b.style.display = show ? '' : 'none';
if(c) c.classList.toggle('open', show);
}
const openPreset = (id)=>openItem('preset', id);
const openMem = (n)=>openItem('mem', n);
// Fermer la modale n'annule PAS un téléchargement en cours (il vit côté
// serveur) : on arrête juste de l'interroger.
function closeModal(){ hideModal('modal'); document.getElementById('modal').classList.remove('loading'); stopDlPoll(); }
async function saveItem(){
const K = KINDS[editingKind];
const name = document.getElementById('m-name').value.trim();
const content = document.getElementById('m-content').value;
if(!name){ toast('nom requis'); return; }
// Presets: keyed by id (filename); duplicate display names are allowed.
// Skills: keyed by name, rename via `old`.
const payload = editingKind==='preset'
? {id: editingKey, name, content}
: {name, old: editingKey, content};
const r = await jpost(K.saveUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('enregistré'); closeModal(); K.reload();
}
async function delItem(){
if(!editingKey) return;
const K = KINDS[editingKind];
const name = document.getElementById('m-name').value.trim() || editingKey;
// Le choix « supprimer aussi le .gguf » est posé DANS la confirmation : il n'a
// de sens qu'au moment de supprimer, et il occupait le pied de l'éditeur en
// permanence. Décoché à chaque ouverture — jamais de modèle effacé parce que
// la case serait restée cochée d'une fois sur l'autre.
const msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?'
+ (editingKind==='preset' ? '\n\nLe fichier .gguf du modèle est conservé, sauf si vous cochez ci-dessous (irréversible).' : '');
const opts = {title:'Suppression', okText:'Supprimer', danger:true};
if(editingKind==='preset') opts.check = 'supprimer aussi le fichier .gguf';
if(!await askConfirm(msg, opts)) return;
const delModel = editingKind==='preset' && askChecked();
const payload = editingKind==='preset'
? {id: editingKey, deleteModel: delModel}
: {name: editingKey};
const r = await jpost(K.delUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
if(delModel){
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
else if(r.modelDeleted) toast('preset + modèle supprimés');
else toast('supprimé (aucun modèle référencé)');
} else { toast('supprimé'); }
closeModal(); K.reload();
}
// Download a .gguf from Hugging Face. Le téléchargement vit côté serveur : fermer
// la modale ne l'interrompt pas, on se rebranche dessus à la réouverture
// (attachDownload) et seul « Annuler » l'arrête vraiment.
let dlPoll = null, dlName = '';
function dlEls(){
return {
btn: document.getElementById('m-hf-btn'),
prog: document.getElementById('m-hf-progress'),
bar: document.getElementById('m-hf-bar'),
cancel: document.getElementById('m-hf-cancel'),
};
}
// Arrête le poll local sans toucher au téléchargement serveur.
function stopDlPoll(){ if(dlPoll){ clearInterval(dlPoll); dlPoll=null; } }
// Remet la zone de téléchargement à zéro (réouverture de la modale).
function resetDlUI(){
const e = dlEls();
stopDlPoll(); dlName='';
e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false;
}
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
// de choisir, et ça évite une ligne de plus dans une modale déjà chargée.
// Même source que « Dossiers de modèles » : ajouter un disque externe là-bas le
// rend aussitôt disponible ici.
let dlDirList = [];
async function populateDlDirs(){
const sel = document.getElementById('m-hf-dir');
if(!sel) return;
let d = {};
try{ d = await jget('/api/models/dirs'); }catch(_){ return; }
dlDirList = d.dirs || [];
const prev = sel.value;
sel.innerHTML = '';
for(const x of dlDirList){
const o = document.createElement('option');
o.value = x.path;
o.textContent = x.path + (x.free >= 0 ? ' — ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '');
sel.append(o);
}
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
}
// File d'attente : installer un modèle avec son projecteur, c'est DEUX
// téléchargements, et le serveur n'en mène qu'un à la fois (une seule barre,
// un seul poll). On enchaîne donc à la fin de chacun plutôt que de les lancer
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée.
let dlQueue = [];
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
return startDownloadURL(url);
}
async function startDownloadURL(url){
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
const e = dlEls();
e.btn.disabled = true;
e.prog.style.display = 'block';
e.prog.textContent = 'vérification de la taille et de l’espace disque…';
e.bar.style.display = 'block';
e.bar.className = 'pe-bar indet';
e.bar.firstElementChild.style.width = '';
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
const p = await jpost('/api/models/download/probe', {url, dir});
if(!p.ok || !p.enough){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
dlQueue = []; // le reste de la file dépendait de ce fichier : on n'enchaîne pas
await populateDlDirs();
return;
}
// p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas
// les 15 Go du fichier dont le lien a été collé.
const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : '';
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '
+ (p.free_exact === false ? '~' : '') + fmtSize(p.free) + ' libres'
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
}
watchDownload(r.filename);
}
// Annule le téléchargement en cours : le serveur coupe les connexions et
// supprime le fichier partiel.
async function cancelDownload(){
if(!dlName) return;
const e = dlEls();
e.cancel.disabled = true;
await jpost('/api/models/download/cancel', {filename: dlName});
// L'état « annulé » est confirmé par le prochain tick de poll.
}
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
function watchDownload(fname){
const e = dlEls();
dlName = fname;
stopDlPoll();
e.btn.disabled = true;
e.prog.style.display = 'block';
e.bar.style.display = 'block';
e.cancel.style.display = 'inline';
e.cancel.disabled = false;
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
const tick = async ()=>{
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>d.filename===fname);
if(!st) return;
if(st.canceled){
e.prog.textContent = 'téléchargement annulé — '+fname;
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
}
if(st.error){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
}
if(st.finished){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
// complet, pas le simple nom de fichier.
const pick = (id, cb)=>{
const s = document.getElementById(id);
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(o){ s.value = o.value; cb(); }
};
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
else pick('m-model', onPickModel);
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné.
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
const pct = st.total>0 ? st.done*100/st.total : 0;
if(st.total>0){
e.bar.className = 'pe-bar';
e.bar.firstElementChild.style.width = Math.min(100, pct).toFixed(1)+'%';
} else {
e.bar.className = 'pe-bar indet';
}
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
let extra = '';
if(st.speed>0){
extra += ' — '+fmtSize(st.speed)+'/s';
if(st.total>0){
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
const m = Math.floor(eta/60), s = eta%60;
extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s');
}
}
// Une seule barre pour tout le modèle ; le compteur de tranches dit où on en
// est, sans quoi un téléchargement en 3 fichiers semble se figer par paliers.
const part = st.parts > 1 ? ' — fichier '+(st.part||1)+'/'+st.parts : '';
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+part+' — '+fname;
};
dlPoll = setInterval(tick, 800);
tick();
}
// À l'ouverture de la modale : se rebrancher sur un téléchargement encore en
// cours côté serveur (modale fermée entre-temps, page rechargée, autre appareil).
async function attachDownload(){
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>!d.finished);
if(st) watchDownload(st.filename);
}
// --- Recherche Hugging Face -------------------------------------------
// Deux écrans : la liste des dépôts, puis les fichiers d'un dépôt. Tout est
// construit en DOM et JAMAIS en innerHTML — noms de dépôts et de fichiers
// viennent d'un tiers, c'est la même règle que pour les titres de discussions.
let hfRepoFiles = null; // dernier dépôt déplié, pour l'installation
function hfOut(){ return document.getElementById('hf-out'); }
function hfMsg(txt, err){
const o = hfOut(); if(!o) return;
o.textContent = '';
const d = document.createElement('div');
d.className = 'pe-note';
if(err) d.style.color = 'var(--err)';
d.textContent = txt;
o.appendChild(d);
}
// Remet la zone de recherche à zéro (réouverture de la modale).
function resetHfUI(){
hfRepoFiles = null;
const o = hfOut(); if(o) o.textContent = '';
const q = document.getElementById('hf-q'); if(q) q.value = '';
}
async function hfSearch(){
const q = (document.getElementById('hf-q').value || '').trim();
if(!q){ toast('tape un nom de modèle'); return; }
hfMsg('recherche…');
let r;
try{ r = await jget('/api/hf/search?q='+encodeURIComponent(q)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'recherche impossible', true); return; }
if(!r.repos || !r.repos.length){ hfMsg('aucun dépôt GGUF pour « '+q+' »'); return; }
const o = hfOut(); o.textContent = '';
const list = document.createElement('div'); list.className = 'hf-list';
for(const rep of r.repos){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfPickRepo(rep.id);
const n = document.createElement('span'); n.className = 'hf-name'; n.textContent = rep.id;
const m = document.createElement('span'); m.className = 'hf-meta';
m.textContent = fmtCount(rep.downloads)+' ↓';
row.append(n, m);
// Dépôt verrouillé : il se LIT sans rien, mais chaque .gguf répond 401 au
// transfert. La pastille le dit dès la liste — sinon on choisit un quant,
// on lance 15 Go et l'erreur tombe à la fin de la sonde.
if(rep.gated){
const g = document.createElement('span');
g.className = 'hf-fit gated';
g.textContent = 'accès restreint';
g.title = hfGatedHint(r.hf_token);
row.appendChild(g);
}
list.appendChild(row);
}
o.appendChild(list);
}
// Le geste à faire n'est pas le même selon qu'un jeton est configuré ou non :
// sans jeton il en faut un, avec jeton c'est l'accès au dépôt qui manque.
function hfGatedHint(hasToken){
return hasToken
? 'Dépôt à accès restreint : le jeton enregistré sera utilisé. Si le téléchargement échoue en 401, accepte les conditions du dépôt sur huggingface.co avec le compte de ce jeton.'
: 'Dépôt à accès restreint : accepte ses conditions sur huggingface.co, puis enregistre un jeton dans « Jeton Hugging Face » plus bas. Sans jeton, le téléchargement échouera en 401.';
}
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
// « celui-là est le plus utilisé », pas son compte exact.
function fmtCount(n){
n = n || 0;
if(n >= 1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+' M';
if(n >= 1e3) return Math.round(n/1e3)+' k';
return String(n);
}
async function hfPickRepo(repo){
hfMsg('lecture du dépôt…');
let r;
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
const o = hfOut(); o.textContent = '';
const head = document.createElement('div');
head.className = 'hf-head';
const back = document.createElement('button');
back.className = 'pe-link'; back.textContent = '← résultats';
back.onclick = hfSearch;
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
head.append(back, title);
if(r.gated){
const g = document.createElement('span');
g.className = 'hf-fit gated'; g.textContent = 'accès restreint';
head.appendChild(g);
}
o.appendChild(head);
// Avertissement en toutes lettres : la pastille seule ne dit pas quoi faire,
// et c'est ici que le téléchargement se déclenche.
if(r.gated){
const warn = document.createElement('div');
warn.className = 'pe-note';
warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)';
warn.textContent = hfGatedHint(r.hf_token);
o.appendChild(warn);
// Le réglage est plus bas dans la même fenêtre : y mener directement évite
// de le chercher, et c'est le seul geste qui débloque ce dépôt.
if(!r.hf_token){
const go = document.createElement('button');
go.className = 'pe-link'; go.textContent = 'Renseigner un jeton Hugging Face';
go.onclick = ()=>{
setHFTokenOpen(true);
const inp = document.getElementById('m-hft-input');
document.getElementById('m-hft-toggle').scrollIntoView({block:'center', behavior:'smooth'});
if(inp) inp.focus();
};
o.appendChild(go);
}
}
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
// dit — on ne va pas en chercher un.
let mmChk = null;
const mm = (r.projectors||[])[0] && hfBestProjector(r.projectors);
const note = document.createElement('label');
note.className = 'hf-mm';
if(mm){
mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
note.append(mmChk, t);
} else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
note.classList.add('muted');
}
o.appendChild(note);
const list = document.createElement('div'); list.className = 'hf-list';
for(const m of r.models){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null);
const n = document.createElement('span'); n.className = 'hf-name';
n.textContent = m.quant || m.name;
const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta);
if(m.verdict){
const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict;
fit.textContent = m.verdict;
fit.title = m.why || '';
row.appendChild(fit);
}
list.appendChild(row);
}
o.appendChild(list);
if(r.hardware){
const hw = document.createElement('div');
hw.className = 'pe-note muted';
hw.textContent = r.hardware.vram_gb > 0
? 'Verdict calculé sur '+r.hardware.vram_gb.toFixed(1)+' Go de VRAM et un contexte de '+r.ctx+' jetons (estimation).'
: 'Aucun GPU détecté : verdict calculé sur '+r.hardware.ram_gb.toFixed(1)+' Go de RAM (estimation).';
o.appendChild(hw);
}
}
// Q8_0 d'abord (629 Mo contre 931 pour le BF16, sans différence perceptible sur
// un encodeur d'images), sinon le plus léger. Même règle que hfPickProjector
// côté serveur — l'UI choisit pour ne pas imposer un menu de plus.
function hfBestProjector(list){
return list.find(p => /^Q8_0$/i.test(p.quant || '')) || list.slice().sort((a,b)=>a.size-b.size)[0];
}
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
document.getElementById('m-hf-url').value = model.url;
await startDownloadURL(model.url);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
// up. Re-stick when they scroll back near bottom themselves.
let stickyBottom = true;
const chatEl = () => document.getElementById('chat');
function isNearBottom(){
const c = chatEl();
return c.scrollHeight - c.scrollTop - c.clientHeight < 60;
}
// La zone de chat réserve une gouttière de barre de défilement de chaque côté
// (scrollbar-gutter: stable both-edges) ; le composer, lui, n'est pas défilant.
// On mesure la gouttière réelle et on la reporte sur le composer, sinon les
// messages sont en retrait par rapport à la zone de saisie — décalage visible
// surtout quand la barre latérale est escamotée.
function syncGutter(){
const chat=document.getElementById('chat'); if(!chat) return;
const g=Math.max(0,(chat.offsetWidth-chat.clientWidth)/2);
document.documentElement.style.setProperty('--sbw', g+'px');
}
// La carte de saisie flotte AU-DESSUS du fil (position:absolute) : c'est au fil
// de réserver sa hauteur en rembourrage bas, sinon ses derniers messages passent
// dessous et deviennent hors d'atteinte — aucun défilement ne les ramène.
// Cette hauteur n'est pas constante : la saisie grandit avec le texte (autoGrow),
// et une pièce jointe en attente ajoute une rangée de pastilles. On la mesure
// donc au lieu de la deviner, comme on le fait déjà pour la gouttière.
// Pas de boucle d'observation : le composeur est en position:absolute, changer le
// rembourrage de #chat ne modifie pas sa hauteur.
function syncComposer(){
const c=document.getElementById('composer'); if(!c) return;
document.documentElement.style.setProperty('--composer-h', c.offsetHeight+'px');
}
addEventListener('resize', syncGutter);
addEventListener('DOMContentLoaded', syncGutter);
addEventListener('resize', syncComposer);
addEventListener('DOMContentLoaded', ()=>{
syncComposer();
const c=document.getElementById('composer');
if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c);
});
function scrollMaybe(){
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
if(REPLAYING) return;
if(stickyBottom){
const c = chatEl();
c.scrollTop = c.scrollHeight;
}
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
}
function jumpBottom(){ stickyBottom = true; scrollMaybe(); }
document.addEventListener('DOMContentLoaded', ()=>{
const c = chatEl();
c.addEventListener('scroll', ()=>{
stickyBottom = isNearBottom();
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
});
});
// Badge de rôle du mode code (builder / verifier) : posé par 09-stream.js à
// réception d'un delta {role}, repris par addMsg sur les bulles suivantes.
let ROLE_BADGE='';
function addMsg(role, text){
const el=document.createElement('div');
el.className='msg '+role;
if(ROLE_BADGE && (role==='assistant'||role==='reasoning'||role==='tool')) el.dataset.agentRole=ROLE_BADGE;
const collapsible = (role==='reasoning' || role==='tool');
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
if(collapsible){
el.classList.add('collapsible');
el.innerHTML='<span class="label">'+role+'</span><div class="bodywrap"><div class="body"></div></div>';
el.querySelector('.label').onclick=()=>toggleCollapse(el);
} else {
el.innerHTML='<span class="label">'+role+'</span><div class="body"></div>';
// user / assistant portent un avatar et un nom (voir 17-identity.js) ;
// reasoning et tool gardent leur libellé technique.
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
paintLabel(el.querySelector('.label'), role);
}
// Badge visible sur la bulle assistant pendant une passe de rôle.
if(ROLE_BADGE && role==='assistant'){
const b=document.createElement('span'); b.className='rolebadge'; b.textContent=ROLE_BADGE==='verifier'?'vérification':ROLE_BADGE;
el.querySelector('.label').appendChild(b);
}
}
el.querySelector('.body').textContent=text;
// Bulles techniques (raisonnement, outils, mémoire) : elles se posent CÔTE À
// CÔTE dans une rangée qui déborde à la ligne. Un tour d'agent en enchaîne
// parfois dix ; empilées, elles repoussaient la réponse hors de l'écran alors
// qu'elles ne portent qu'une étiquette tant qu'elles sont repliées. Dépliée,
// une bulle reprend toute la largeur de la rangée (CSS .toolrow).
if(collapsible){
let row = chatEl().lastElementChild;
if(!row || !row.classList.contains('toolrow')){
row = document.createElement('div');
row.className = 'toolrow';
chatEl().appendChild(row);
}
row.appendChild(el);
} else {
chatEl().appendChild(el);
}
scrollMaybe();
return el;
}
// Bulle « … » animée affichée dès l'envoi, retirée au 1er token/outil/erreur.
function addTyping(){
const el=document.createElement('div');
el.className='msg assistant typing';
el.innerHTML='<span></span><span></span><span></span>';
chatEl().appendChild(el); scrollMaybe();
return el;
}
// Replie/déplie en douceur les bulles reasoning/tool. Hauteur animée en JS :
// on fige scrollHeight puis on va à 0 (fermeture) ou de 0 vers scrollHeight
// (ouverture), sans jamais dépasser. overflow:hidden clippe pendant l'animation.
function collapseBody(el){
const bw=el.querySelector('.bodywrap'); if(!bw || el.classList.contains('collapsed')) return;
bw.style.height = bw.scrollHeight+'px'; // fige les dimensions courantes
bw.style.width = bw.scrollWidth+'px';
void bw.offsetHeight; // reflow pour que la transition parte de là
el.classList.add('collapsed');
bw.style.height = '0px'; // → anime height ET width vers 0
bw.style.width = '0px';
}
function expandBody(el){
const bw=el.querySelector('.bodywrap'); if(!bw) return;
el.classList.remove('collapsed');
bw.style.height=''; bw.style.width=''; // mesure les dimensions naturelles…
const h=bw.scrollHeight, w=bw.scrollWidth;
bw.style.height='0px'; bw.style.width='0px';// …repart de 0 (pas de flash, même frame)
void bw.offsetHeight;
bw.style.height=h+'px'; bw.style.width=w+'px';
const done=e=>{ if(e.propertyName!=='height') return; bw.style.height=''; bw.style.width=''; bw.removeEventListener('transitionend',done); };
bw.addEventListener('transitionend',done);
}
function toggleCollapse(el){ el.classList.contains('collapsed') ? expandBody(el) : collapseBody(el); }
// Replie toutes les bulles d'un tour une fois la réponse finale entamée.
function collapseAll(list){ for(const el of list){ if(el) collapseBody(el); } list.length=0; }
// Replie une bulle INSTANTANÉMENT (sans animation) — utilisé pendant le replay au
// chargement pour que les vieilles bulles apparaissent déjà fermées. La classe
// 'collapsed' seule ne gère que l'opacité ; la hauteur est en style inline, donc
// on la met à 0 transition désactivée.
function collapseInstant(el){
const bw=el.querySelector('.bodywrap'); if(!bw) return;
el.classList.add('collapsed');
// Pas de `void bw.offsetHeight` ici : la bulle vient d'être créée et n'a jamais
// été peinte dépliée, donc poser height:0 n'anime pas — inutile de forcer un
// reflow par bulle (ce qui, multiplié par le replay, coûtait très cher).
bw.style.transition='none';
bw.style.height='0px'; bw.style.width='0px';
requestAnimationFrame(()=>{ bw.style.transition=''; });
}
function setLabel(el, text){ el.querySelector('.label').textContent = text; }
// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible
// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture
// survit au repli, donc on le met là plutôt que dans le corps seul.
function setLabelCounts(el, add, del){
const lab=el.querySelector('.label');
const cnt=document.createElement('span'); cnt.className='diff-count';
if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add}));
if(add && del) cnt.appendChild(document.createTextNode(' '));
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
lab.appendChild(cnt);
}
// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki sont masquées dans
// cette mise en page, donc les chiffres qu'on y écrivait avaient disparu : ils
// ont leur propre ligne, discrète, sous le texte.
//
// Deux informations de nature différente y cohabitent, d'où deux éléments :
// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la
// réponse, outils et raisonnement compris) ;
// .spd — les mesures de vitesse (prefill / decode).
// Seule la seconde est escamotée par « masquer la vitesse de génération » : la
// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en
// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse.
function setStats(el, work, speed){
if(!el) return;
let s = el.querySelector(':scope > .statline');
if(!work && !speed){ if(s) s.textContent=''; return; }
if(!s){
s=document.createElement('div'); s.className='statline';
// Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois
// la réponse finie, un surgissement sec accrochait l'œil. Les mises à jour
// suivantes ne rejouent pas l'animation (elle clignoterait), et le rejeu du
// journal au chargement n'anime rien du tout.
if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in');
el.appendChild(s);
}
// Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne
// est repeinte à chaque token reçu, y recréer des éléments ferait travailler la
// mise en page des dizaines de fois par seconde pour rien.
let w = s.querySelector(':scope > .worktime');
let p = s.querySelector(':scope > .spd');
if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); }
if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); }
w.textContent = work || '';
// Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser
// un « · » orphelin en bout de durée.
p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : '';
s.classList.toggle('has-work', !!work);
}
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); hydrateImages(b); scrollMaybe(); }
// Images servies par Loki (/api/chat/image) : une balise <img> ne peut pas
// porter d'en-tête Authorization, or /api/* exige la clé de pilotage dès qu'elle
// est définie. On récupère donc l'image par fetch authentifié et on la pose en
// blob:. Sans ça, toute instance protégée par une clé n'affichait que des images
// cassées. Les URLs externes (http…) ne sont pas touchées.
// Cache des blobs par URL source. renderBody est rappelé à CHAQUE delta du
// streaming et reconstruit le DOM : sans cache, une image déjà affichée était
// re-téléchargée à chaque token arrivé après elle. On garde l'objet URL (pas de
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
// casserait les rendus suivants qui réutilisent la même entrée.
const IMG_CACHE = new Map();
function hydrateImages(root){
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
if(img.dataset.hydrated) return;
img.dataset.hydrated = '1';
const src = img.getAttribute('src');
img.classList.add('chatimg');
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
let name = img.getAttribute('alt') || '';
if(!name){
const m = /[?&]path=([^&]*)/.exec(src);
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
}
bindZoom(img, name);
const cached = IMG_CACHE.get(src);
if(cached){ img.src = cached; return; }
try{
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
if(!r.ok) throw new Error(r.status);
const url = URL.createObjectURL(await r.blob());
IMG_CACHE.set(src, url);
img.src = url;
}catch(e){
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
// tronquée en cours de frappe (404 transitoire) et le prochain delta
// re-rend le markdown complet. On note l'échec sans le graver.
const note = document.createElement('span');
note.className = 'muted';
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
img.replaceWith(note);
}
});
}
// Render a tool call as its own conversation message: the command the model
// wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){
// Métadonnées d'affichage par outil : nom court + en-tête. Les outils web
// (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire.
const META = {
bash: {lbl:'terminal', head:'commande'},
write: {lbl:'fichier', head:'écriture'},
edit: {lbl:'édition', head:'édition'},
web_search: {lbl:'recherche', head:'recherche web'},
web_open: {lbl:'page web', head:'ouverture'},
web_read: {lbl:'page web', head:'lecture'},
web_grep: {lbl:'page web', head:'recherche'},
// Outils du mode code : chacun son étiquette, pas le fallback mémoire.
read: {lbl:'lecture', head:'lecture'},
grep: {lbl:'recherche', head:'grep'},
glob: {lbl:'fichiers', head:'glob'},
criteria: {lbl:'critères', head:'critères'},
ask: {lbl:'question', head:'question'},
bash_bg: {lbl:'terminal', head:'commande (fond)'},
bash_tail: {lbl:'terminal', head:'suivi de job'},
git_status: {lbl:'git', head:'git status'},
git_diff: {lbl:'git', head:'git diff'},
git_clone: {lbl:'git', head:'git clone'},
};
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
let meta = META[tu.name];
if(!meta && tu.name && tu.name.indexOf('mcp__')===0){
const parts = tu.name.slice(5).split('__');
const server = parts.shift() || 'mcp';
const tool = parts.join('__') || tu.name;
meta = {lbl: tool, head: server};
}
meta = meta || {lbl:'mémoire', head:'mémoire'};
let lbl = meta.lbl;
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
setLabel(el, lbl);
// Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
// reporté sur l'étiquette, pour rester lisible bulle repliée.
let add=0, del=0;
if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
else if(tu.body){ add=tu.body.split('\n').length; }
if(add||del) setLabelCounts(el, add, del);
const body=bodyOf(el); body.innerHTML='';
const head=document.createElement('div'); head.className='tool-head';
head.textContent = meta.head;
body.appendChild(head);
if(tu.label){
const pre=document.createElement('pre'); pre.className='tool-cmd';
const code=document.createElement('code'); code.textContent=tu.label;
if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
pre.appendChild(code); body.appendChild(pre);
}
// Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à
// ligne, dans la même forme que le diff final, pour que la bulle se remplisse
// sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la
// dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement
// ferait clignoter le bloc entier.
if(tu.body && !(tu.diff && tu.diff.length)){
const lines=tu.body.split('\n');
const sub=document.createElement('div'); sub.className='tool-sub';
sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée)
body.appendChild(sub);
const pre=document.createElement('pre'); pre.className='diff live';
lines.forEach((t,i)=>{
const ln=document.createElement('span');
ln.className='dl add'+(i===lines.length-1?' fresh':'');
ln.textContent='+ '+t;
if(i===lines.length-1 && tu.typing){
const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋';
ln.appendChild(car);
}
pre.appendChild(ln);
});
body.appendChild(pre);
// Le bloc est re-créé à chaque événement : on le recale en bas pour suivre
// la ligne en cours (max-height côté CSS l'empêche de pousser le fil).
pre.scrollTop = pre.scrollHeight;
}
// Capture d'écran : l'image est rendue ICI, dans la bulle de l'outil. Elle ne
// dépend donc plus de la bonne volonté du modèle à recopier la ligne markdown
// — il l'oubliait, et l'utilisateur ne voyait jamais ce qu'il avait demandé.
if(tu.image){
const wrap=document.createElement('div'); wrap.className='tool-shot';
const img=document.createElement('img');
// src (et non data-src) : hydrateImages sélectionne sur src, puis le
// remplace par un blob obtenu avec la clé de pilotage.
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(tu.image));
img.alt = tu.image.split('/').pop();
img.loading = 'lazy';
wrap.appendChild(img);
body.appendChild(wrap);
hydrateImages(wrap);
}
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
// retirées en rouge, contexte en gris — comme un diff de terminal.
if(tu.diff && tu.diff.length){
const sub=document.createElement('div'); sub.className='tool-sub';
sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée)
body.appendChild(sub);
const pre=document.createElement('pre'); pre.className='diff';
tu.diff.forEach(l=>{
const ln=document.createElement('span');
ln.className='dl'+(l.op==='+'?' add':l.op==='-'?' del':'');
ln.textContent=(l.op==='+'?'+':l.op==='-'?'-':' ')+' '+l.text;
pre.appendChild(ln);
});
body.appendChild(pre);
}
const hasResult = tu.result!==undefined && tu.result!=='';
if(hasResult){
const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
body.appendChild(sub);
const pre=document.createElement('pre');
const code=document.createElement('code'); code.textContent=tu.result;
pre.appendChild(code); body.appendChild(pre);
} else if(!tu.done && !tu.typing){
const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…';
body.appendChild(wait);
}
addCopyButtons(body); scrollMaybe();
// Carte à hauteur bornée : tant que l'outil TRAVAILLE, on suit le bas (la
// ligne en cours d'écriture, la commande qui se tape). Terminé, on remonte en
// tête — c'est le résumé qu'on relit, pas la dernière ligne.
const bw = el.querySelector('.bodywrap');
if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
}
// Inject a "copier" button into every <pre> code block (idempotent).
function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{
if(pre.querySelector('.copybtn')) return;
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
if(pre.classList.contains('diff')) return;
const btn=document.createElement('button');
btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
btn.onclick=async(e)=>{
e.stopPropagation();
const code=pre.querySelector('code'), txt=(code||pre).innerText;
try{ await navigator.clipboard.writeText(txt); }
catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
btn.textContent='copié ✓'; btn.classList.add('done');
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
};
pre.appendChild(btn);
});
}
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
//
// 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.
// Compaction MANUELLE : le compactage est automatique (façon Hermes) quand le
// contexte se remplit, mais ce bouton permet de le déclencher à la demande. Le
// serveur possède la conversation : on lance la compaction côté serveur et la
// progression (bannière « compactage en cours », résultat) arrive par le flux
// d'abonnement, comme pour la génération — donc visible sur tous les appareils.
async function compactContext(){
if(!await askConfirm('Résumer les anciens tours pour libérer du contexte ? La conversation continue normalement.', {title:'Compacter le contexte', okText:'Compacter'})) return;
const btn=document.getElementById('ctx-compact'); btn.disabled=true;
try{
const r=await jfetch('/api/chat/compact',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json().catch(()=>({}));
if(!j.ok) toast(j.error||'compaction indisponible');
}catch(e){ toast('erreur : '+(e.message||e)); }
btn.disabled=false;
}
// Persistance de la conversation : on garde user+assistant en localStorage pour
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
function saveChat(){ try{ localStorage.setItem('loki.chat', JSON.stringify(msgs)); }catch(e){} }
// Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement
// permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte,
// appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de
// localStorage : le même contexte est partagé par tous les appareils.
// Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue
// tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement)
// puis suit le direct. Partagé par tous les appareils.
// Voile de chargement du fil. setChatLoading(null) le masque, setChatLoading(txt)
// l'affiche avec ce libellé (« chargement… » au départ, « connexion au serveur… »
// si le flux tombe). Sans lui, une connexion lente affiche un chat vide qu'on ne
// distingue pas d'une conversation réellement vide.
function setChatLoading(msg){
const el=document.getElementById('chat-loading');
if(!el) return;
if(!msg){ el.classList.remove('show'); return; }
document.getElementById('chat-loading-text').textContent=msg;
el.classList.add('show');
}
// --- Accueil du fil vide ---------------------------------------------------
// Le logo n'est pas dupliqué dans le HTML : on clone celui de la barre latérale
// (#brand) en retirant ses id (un id ne peut exister qu'une fois) et le numéro
// de version. Les couleurs sont reprises par les classes .ce-*.
function fillEmptyLogo(){
const box=document.getElementById('ce-logo'), brand=document.getElementById('brand');
if(!box || !brand || box.childElementCount) return;
['brand-a','brand-word'].forEach(id=>{
const src=brand.querySelector('#'+id); if(!src) return;
const el=src.cloneNode(true); el.removeAttribute('id');
if(id==='brand-word') el.classList.add('ce-word');
box.appendChild(el);
});
}
// Affiché seulement quand le fil ne contient AUCUNE bulle et que le replay est
// terminé — sinon il apparaîtrait une fraction de seconde à chaque chargement,
// juste avant que les messages rejoués n'arrivent.
function syncChatEmpty(){
const box=document.getElementById('chat-empty'); if(!box) return;
fillEmptyLogo();
const empty = !REPLAYING && !chatEl().querySelector('.msg');
box.classList.toggle('show', empty);
}
document.addEventListener('DOMContentLoaded', ()=>{
const c=chatEl(); if(!c) return;
// Le fil est peuplé par des dizaines de chemins différents (replay, direct,
// reset, effacement). On observe donc le DOM plutôt que d'appeler la synchro
// depuis chacun d'eux — le coût est nul, le callback est groupé et sort tout
// de suite pendant le replay.
new MutationObserver(()=>syncChatEmpty()).observe(c, {childList:true});
syncChatEmpty();
});
function restoreChat(){
// On masque le chat le temps du replay pour ne pas voir défiler le haut puis
// sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au
// signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s.
const c=chatEl(); c.style.opacity='0';
setChatLoading('chargement de la conversation…');
// Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand
// même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que
// REPLAYING, donc on force ici le positionnement). Le voile, lui, RESTE : tant
// que le replay n'est pas fini, ce qui est affiché est incomplet et il faut le
// dire. Il finit de toute façon par tomber au {caught_up} ou au filet de 15s.
setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000);
setTimeout(()=>{ setChatLoading(null); }, 15000);
connectStream();
}
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
// La zone de saisie s'ajuste à son contenu : une ligne au repos, puis elle
// grandit jusqu'à sa max-height (au-delà, elle défile). Appelée à la frappe, à
// l'envoi et au chargement.
function autoGrow(ta){
ta = ta || document.getElementById('input');
if(!ta) return;
ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden';
}
// ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
// L'historique et la génération vivent sur le serveur loki. Le client ouvre un
// flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
let lastSeq=0, streamAbort=null;
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
// l'événement `user` revient par le flux — preuve que le serveur l'a bien
// enregistré. En cas d'échec d'envoi, elle est retirée et le texte est rendu.
let PENDING=null;
// Retire aussi la rangée de pièces jointes, qui vit JUSTE AVANT la bulle : sans
// ça, un envoi échoué laissait les fichiers seuls dans le fil, sans message.
function clearPending(){
if(!PENDING) return;
const f=PENDING.previousElementSibling;
if(f&&f.classList.contains('msg-files')) f.remove();
PENDING.remove(); PENDING=null;
}
function addPending(text){
clearPending();
PENDING=addMsg('user', text);
PENDING.classList.add('pending');
// 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;
}
// Le serveur confirme le message : on réutilise la bulle grise au lieu d'en
// ajouter une seconde (sinon le message clignoterait en double).
function confirmPending(text){
if(!PENDING) return false;
const b=PENDING.querySelector('.body');
if(!b || b.textContent!==text) return false;
PENDING.classList.remove('pending');
// ⚠️ 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;
}
// REPLAYING = on est dans le replay initial (rejeu du journal au chargement).
// Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées →
// pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up}
// quand le replay est fini, on repasse alors en direct.
let REPLAYING=true;
// État de rendu du tour courant, délimité par les événements user / turn_done.
let T=null;
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
newTurn();
// --- Temps de travail du tour ------------------------------------------------
// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la
// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes
// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user`
// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal,
// où tout arrive d'un bloc côté client.
//
// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la
// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un
// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART
// entre les deux, réévalué à chaque événement reçu en direct.
let TS_SKEW = 0; // horloge client − horloge serveur (ms)
function nowServer(){ return Date.now() - TS_SKEW; }
let WORK_TIMER = null;
function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); }
function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } }
function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); paintTurnClock(); }
// Chrono du TOUR COMPLET, affiché au-dessus de la carte de saisie : le temps
// entre l'envoi du message et le moment où l'on peut reparler — outils, passes
// de vérification et compaction compris. Les durées portées par les cartes ne
// disent que la génération de texte ; celle-ci répond à « ça a pris combien de
// temps, en tout ? ».
function paintTurnClock(){
const composer = document.getElementById('composer');
if(!composer || !T.startTs) return;
let el = document.getElementById('turn-clock');
if(!el){
el = document.createElement('div');
el.id = 'turn-clock';
el.className = 'composer-banner';
composer.insertBefore(el, composer.firstChild);
}
const ms = (T.doneTs || nowServer()) - T.startTs;
if(!(ms > 0)){ el.remove(); return; }
// Vitesse moyenne de la CONVERSATION à côté du temps du tour : le débit
// typique de ce modèle sur ce fil, toutes réponses confondues.
const avg = (CONV_MS > 1000 && CONV_TOK > 20)
? ' · moy ' + (CONV_TOK / (CONV_MS/1000)).toFixed(1) + ' tok/s' : '';
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms) + avg;
el.classList.toggle('done', !!T.doneTs);
}
// --- Vitesse moyenne de la conversation --------------------------------------
// Alimentée par les événements `stats` (journalisés, donc rejoués au
// chargement : la moyenne survit au refresh). gen_tokens / gen_ms sont
// CUMULATIFS au sein d'une complétion et retombent à la suivante : on n'ajoute
// que le delta, et une valeur qui recule signale une nouvelle complétion.
let CONV_TOK = 0, CONV_MS = 0, _cgTok = 0, _cgMs = 0;
function feedConvSpeed(s){
const gt = s.gen_tokens || 0, gms = s.gen_ms || 0;
if(!gt) return;
if(gt < _cgTok){ _cgTok = 0; _cgMs = 0; }
CONV_TOK += gt - _cgTok;
CONV_MS += Math.max(0, gms - _cgMs);
_cgTok = gt; _cgMs = gms;
}
function resetConvSpeed(){ CONV_TOK = 0; CONV_MS = 0; _cgTok = 0; _cgMs = 0; }
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
// c'est précisément le moment où l'on se demande si ça avance.
function paintTypingClock(){
const el = T.typingEl;
if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé
if(!T.startTs) return;
const ms = nowServer() - T.startTs;
if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate
let c = el.querySelector('.wclock');
if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); }
c.textContent = fmtDur(ms);
}
// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction),
// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste
// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule
// y ferait deux conventions à trois mots d'écart.
function fmtDur(ms){
if(!(ms > 0)) return '';
if(ms < 10000) return (ms/1000).toFixed(1) + ' s';
const t = Math.round(ms/1000);
if(t < 60) return t + ' s';
const m = Math.floor(t/60), sec = t % 60;
if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s';
return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min';
}
// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour
// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens).
function workLabel(){
if(!T.startTs) return '';
const ms = (T.doneTs || nowServer()) - T.startTs;
if(!(ms > 300)) return '';
return 'travail ' + fmtDur(ms);
}
// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite.
// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le
// cas du tic de seconde, qui ne fait avancer que la durée).
function paintStats(el, speed){
if(speed !== undefined) T.speedText = speed;
setStats(el, workLabel(), T.speedText);
}
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
// recrée, en le marquant pour le reprendre quand le compactage est fini.
function setCompacting(on){
if(on){
if(!T.typingEl){ T.typingEl=addTyping(); T.typingEl.dataset.forCompact='1'; }
T.typingEl.classList.add('compacting');
if(!T.typingEl.querySelector('.tlabel')){
const s=document.createElement('span');
s.className='tlabel';
s.textContent='compactage du contexte…';
T.typingEl.appendChild(s);
}
scrollMaybe();
return;
}
if(!T.typingEl) return;
if(T.typingEl.dataset.forCompact){ removeTyping(); return; }
T.typingEl.classList.remove('compacting');
const s=T.typingEl.querySelector('.tlabel'); if(s) s.remove();
}
// Séparateur laissé dans le fil à l'endroit exact de la coupure.
function addCompactMark(){
const el=document.createElement('div');
el.className='compact-mark';
el.textContent='contexte compacté — anciens tours résumés';
// Devant l'indicateur de frappe s'il est encore là (compactage en cours de
// tour) : la suite de la réponse doit rester APRÈS la marque de coupure.
if(T.typingEl && T.typingEl.parentNode===chatEl()) chatEl().insertBefore(el, T.typingEl);
else chatEl().appendChild(el);
scrollMaybe();
}
// L'indicateur « … » est retiré dès que quelque chose de VISIBLE le remplace.
// Il doit donc survivre quand la bulle qui arrive ne sera pas affichée : mode
// simplifié, mais aussi raisonnement/outils masqués par les préférences — sinon
// le fil reste totalement vide pendant que le modèle travaille (rien à voir, et
// aucun signe que ça tourne).
const typingKept=(kind)=>simpleMode()
|| (kind==='reasoning' && viewOn('hide-reasoning'))
|| (kind==='tool' && viewOn('hide-tools'));
function killTyping(kind){ if(!T.typingEl) return; if(typingKept(kind)) return; removeTyping(); }
function showTyping(kind){ if(!typingKept(kind)) return; const c=document.getElementById('chat');
if(!T.typingEl){ T.typingEl=addTyping(); } else if(c.lastElementChild!==T.typingEl){ c.appendChild(T.typingEl); } }
// Label de vitesse rendu depuis les valeurs (fonctionne aussi bien en direct
// qu'au replay — pas de timer performance.now, qui n'a pas de sens hors-ligne).
function renderStats(el, s){
if(!el||!s) return;
const parts=[];
const pt=s.prompt_tokens||s.prompt_tokens_total;
if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
if(!parts.length) return;
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
// bouton de repli, on y écrit comme avant.
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
else paintStats(el, parts.join(' · '));
}
// --- Compteurs de bulle -----------------------------------------------------
// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle
// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les
// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le
// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier
// token — exécution des outils, pages web et prefill compris.
//
// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait
// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur
// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite
// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert,
// aujourd'hui comme demain.
function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; }
function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; }
// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
// donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un
// bloc côté client, mais leurs ts serveur restent espacés du vrai temps écoulé).
function labelTokens(el, role, n, firstTs, lastTs){
if(!el) return;
const secs=(lastTs-firstTs)/1000;
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
// Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi
// pendant… » d'un fil de discussion — elle reste lisible une fois repliée.
const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : '';
// 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].concat(d||[], m).join(' · '));
else paintStats(el, m);
}
// Badge du modèle sur une bulle de réponse : le nom voyage avec la borne de
// tour (T.model), on le dépose dans dataset.model pour qu'applyIdentity — qui
// repeint les libellés de zéro — puisse le reposer (voir paintLabel).
function tagModel(el){
if(!el || !T.model) return;
el.dataset.model = T.model;
const l = el.querySelector('.label');
if(l && !l.querySelector('.modelbadge') && typeof modelBadge==='function') l.appendChild(modelBadge(T.model));
}
// 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).
// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn();
// La liste des discussions montre laquelle travaille : elle doit suivre l'état
// MÊME pendant le rejeu (une page rechargée en cours de génération doit voir
// l'anneau tourner sans attendre le premier événement en direct).
if(typeof convSyncBusy==='function') convSyncBusy(); }
function syncSendBtn(){
const sb=document.getElementById('send');
// ⚠️ 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
// répond pas du tout) — dans le doute on laisse la main.
const ready = !STATUS_SEEN || MODEL_READY;
sb.disabled = !ready;
// Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le
// modèle charge » serait faux, et on attendrait un chargement qui ne viendra
// jamais. On nomme alors le geste qui remet le modèle en mémoire.
// Trois raisons de ne pas être prêt, trois messages : planté au chargement
// (recharger ne ferait que replanter — la cause est affichée dans le
// moniteur), déchargé à la demande, ou simplement en train de charger.
const failed = !ready && !!LOAD_ERROR;
const unloaded = !ready && !failed && !ENGINE_ACTIVE;
sb.title = ready ? '' : failed ? 'le modèle n\'a pas pu charger — voir l\'erreur dans le moniteur'
: (unloaded ? 'le modèle est déchargé — recharge-le pour écrire'
: 'le modèle n\'est pas encore chargé');
const hint=document.getElementById('sendhint');
if(hint){
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
: failed ? 'Le modèle n\'a pas pu charger — l\'erreur est affichée dans le moniteur, en bas de la barre latérale.'
: unloaded ? 'Modèle déchargé — « Recharger le modèle » dans le moniteur, en bas de la barre latérale.'
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
hint.classList.toggle('waiting', !ready);
}
}
// ─── Rendu du bloc en cours : cadencé, puis lissé ────────────────────────────
// Repris de l'amont AJEAN (v0.9.5 issue #24, puis v0.10.5), adapté.
//
// Re-parser le Markdown du bloc ENTIER à chaque token est en O(n²) : sur un long
// raisonnement (des milliers de tokens) l'interface se met à ramer, les tokens
// semblent arriver au ralenti — alors que le moteur, lui, débite toujours autant
// — et un simple rafraîchissement « répare » tout, puisqu'il rend le bloc une
// seule fois. On coalesce donc : le texte s'accumule (concaténation, quasi
// gratuite) et on ne re-rend qu'à intervalle borné. Le rendu final exact est
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
// tour, erreur, caught_up).
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
function scheduleRender(el, text, final){
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
// valeur, sinon son ultime bout de texte serait perdu.
if(renderPending && renderPending.el!==el) flushRender();
// `final` voyage AVEC le rendu en attente : le timer déjà armé (qui passe
// final=false) peut être celui qui consommera ce rendu — sans le drapeau sur
// le pending, le rendu de FIN de bloc repassait par le chemin « queue seule »
// et le début du raisonnement n'était jamais posé.
renderPending={el, text, final:!!final};
if(renderTimer) return;
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
// 16 ms ≈ 60 img/s, l'apparition reste fluide token par token. Quand le bloc
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
// le O(n²) est cassé sans jamais figer l'interface.
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
renderTimer=setTimeout(()=>flushRender(false), delay);
}
// Au-delà de cette taille, un raisonnement EN COURS n'est re-parsé que sur sa
// fin : re-parser le bloc entier à chaque tick est en O(n²) et finissait par
// figer l'affichage (le texte semblait s'arrêter alors que le moteur débitait).
// La carte étant bornée en hauteur et collée en bas, on ne perd rien à l'écran ;
// le texte COMPLET est posé au rendu final (frontière de bloc → final=true).
const REASON_TAIL=6000;
function flushRender(final){
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
const p=renderPending; renderPending=null;
if(!p) return;
let text=p.text;
if(final===false && !p.final && text.length>REASON_TAIL+800 && p.el.closest('.msg.reasoning')){
let cut=text.length-REASON_TAIL;
const nl=text.indexOf('\n', cut); if(nl>-1 && nl<cut+400) cut=nl+1;
text='*… (début replié pendant la génération — le texte complet est posé à la fin)*\n\n'+text.slice(cut);
}
// Carte bornée (raisonnement/outil) : si on était collé en bas, on y reste —
// la carte suit la génération. Un défilement manuel vers le haut est respecté.
// ⚠️ p.el est la BULLE (.msg), pas .body : le bodywrap est dedans, pas au-dessus.
const bw=p.el.querySelector ? p.el.querySelector(':scope > .bodywrap') : null;
const scrollable=!!bw;
const stick=scrollable && bw.scrollTop+bw.clientHeight>=bw.scrollHeight-60;
const t0=performance.now();
renderBody(p.el, text);
lastRenderMs=performance.now()-t0;
if(scrollable && stick) bw.scrollTop=bw.scrollHeight;
}
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
function cancelRender(){ if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; } renderPending=null; }
// Lissage d'apparition (« machine à écrire »). Le décodage spéculatif (MTP) rend
// les tokens PAR RAFALES — plusieurs acceptés d'un coup, puis une pause : le
// texte grandit par paquets et saute à l'écran. On découple donc l'ARRIVÉE (les
// rafales du moteur) de l'AFFICHAGE : `target` = tout ce qui est reçu, `shown`
// avance à cadence régulière (requestAnimationFrame), et on n'affiche que le
// préfixe révélé. Le rendu passe TOUJOURS par scheduleRender pour conserver le
// garde-fou anti-O(n²) ci-dessus.
// UNIQUEMENT EN DIRECT : au rejeu tout est posé d'un bloc, sinon relire un fil
// deviendrait une lente réécriture caractère par caractère.
let smooth=null, smoothLast=0; // {el, target, shown, raf}
function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf); smooth=null; smoothLast=0; }
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
// texte resterait en retard derrière le curseur de révélation.
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target, true); }
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
// donne un écoulement CONTINU malgré les rafales, et se vide en douceur quand la
// génération s'arrête. Un plancher garantit un progrès même sur un retard
// minuscule, sans jamais figer.
const SMOOTH_TAU=260; // ms — plus grand = plus lisse mais traîne davantage
function smoothStep(ts){
if(!smooth) return;
if(!smoothLast) smoothLast=ts;
const dt=Math.min(120, ts-smoothLast); smoothLast=ts;
const remaining=smooth.target.length - smooth.shown;
if(remaining<=0){ smooth.raf=null; smoothLast=0; return; }
let adv=remaining*dt/SMOOTH_TAU; // vitesse ∝ retard
if(adv<0.4) adv=0.4; // progrès minimal
smooth.shown=Math.min(smooth.target.length, smooth.shown+Math.ceil(adv));
scheduleRender(smooth.el, smooth.target.slice(0, smooth.shown));
smooth.raf=requestAnimationFrame(smoothStep);
}
function smoothFeed(el, target){
if(smooth && smooth.el!==el) smoothSnap(); // changement de bloc : solder l'ancien
if(!smooth) smooth={el, target, shown:0, raf:null};
smooth.target=target;
if(!smooth.raf) smooth.raf=requestAnimationFrame(smoothStep);
}
// Rend un bloc en streaming : lissé en direct, instantané au rejeu.
function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoothFeed(el, full); }
// Solde tout ce qui est en vol : le bloc est terminé, la suite (stats, repli,
// bulle d'outil) doit voir le texte complet.
function settleBlocks(){ smoothSnap(); flushRender(); }
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique.
function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
if(d.caught_up){
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
if(busy && T.startTs && !T.doneTs) startWorkTimer();
// Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
syncChatEmpty();
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. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; }
// Bascule de rôle (mode code) : les blocs en cours appartiennent au rôle
// précédent — on les solde avant de les oublier, sinon leur dernier fragment
// resterait en vol et ne s'afficherait jamais.
if(d.role!==undefined){ settleBlocks(); ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; }
if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
if(d.user!==undefined){
newTurn();
// Modèle du tour : posé par le serveur sur la borne de tour (journalisée),
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
// bulle de réponse du tour.
T.model = d.model || '';
let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
// bulle neuve — sinon elles apparaîtraient en double.
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
// Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure
// locale — c'est ce qui rend la durée juste au rejeu comme en direct.
T.startTs = d.ts || 0; T.doneTs = 0;
setBusy(true); startWorkTimer(); paintTurnClock(); T.typingEl=addTyping(); return; }
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
// replay, qui rejoue tous les tours passés d'un bloc.
if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
// le temps réellement passé et non continuer d'avancer.
T.doneTs = d.ts || T.doneTs || 0;
if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
paintTurnClock(); // fige le total au-dessus de la carte
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
// donc une notification persistée se re-déclenchait à chaque rafraîchissement
// (« rien à compacter » qui revient sans raison). C'est un événement ponctuel,
// il n'a de sens qu'en direct — contrairement à la marque `compacted`, qui est
// une trace du fil et DOIT être rejouée.
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
// certains llama-server récents : le serveur publie alors un `ctx_used`
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
// zéro ici, on la laisse simplement inchangée.
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
// Une carte reste OUVERTE tant que SA section travaille (hauteur bornée à
// 280 px, défilement qui suit l'écriture), et se replie DÈS qu'elle est
// finie : l'outil à son résultat (tu.done, plus bas), le raisonnement quand
// le bloc suivant démarre (collapseAll à la création de la carte suivante).
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
renderToolMsg(T.pendingToolEl, tu);
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
if(tu.done){
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
// tour (collapseAll) la fermerait quand même.
if(tu.image && T.pendingToolEl){ const i=T.turnCollapsibles.indexOf(T.pendingToolEl); if(i>=0) T.turnCollapsibles.splice(i,1); }
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
return; }
if(d.drop_reasoning){
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; }
return; }
if(d.reasoning_content){
killTyping('reasoning');
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
// au lieu de concaténer, sinon le texte apparaît en double.
if(d.replace){ smoothSnap(); T.fullReason=''; resetReasonStats(); }
showTyping('reasoning'); T.fullReason+=d.reasoning_content; feedBlock(T.reasonEl, T.fullReason);
// d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs
// tokens d'un coup. Sinon (direct), 1 token, ts0=ts.
if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1);
labelTokens(T.reasonEl, 'reasoning', T.reasonTok, T.reasonFirstTs, T.reasonLastTs);
return; }
if(d.content){
removeTyping();
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); tagModel(T.contentEl); T.fullContent=''; resetContentStats(); }
if(d.replace){ smoothSnap(); T.fullContent=''; resetContentStats(); }
T.fullContent+=d.content; feedBlock(T.contentEl, T.fullContent);
if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1);
labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
return; }
}
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
// re-téléchargement complet après une coupure / bascule d'appareil).
// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Loki laissé
// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
// au-delà, toute requête (journal, installation du moteur…) reste en file
// d'attente sans jamais partir ni échouer — un blocage silencieux très
// déroutant. Au retour de l'onglet on se reconnecte, et le replay depuis
// lastSeq rattrape tout ce qui s'est passé entre-temps.
let streamPaused=false;
document.addEventListener('visibilitychange', ()=>{
if(document.hidden){
streamPaused=true;
if(streamAbort) try{ streamAbort.abort(); }catch(e){}
} else if(streamPaused){
streamPaused=false;
}
});
async function connectStream(){
while(true){
// Onglet en arrière-plan : on n'ouvre aucune connexion, on attend le retour.
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController();
try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
const {done,value}=await reader.read(); if(done) break;
buf+=dec.decode(value,{stream:true}); let i;
while((i=buf.indexOf('\n\n'))>=0){
const chunk=buf.slice(0,i); buf=buf.slice(i+2);
for(const line of chunk.split('\n')){
if(!line.startsWith('data:')) continue;
const data=line.slice(5).trim(); if(data===''||data==='[DONE]') continue;
try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; handleDelta(d); }catch(e){}
}
}
}
}catch(e){ /* coupure : on reconnecte silencieusement */ }
// Le flux s'est arrêté (coupure ou fin prématurée). Si le fil n'a JAMAIS fini
// de charger, le silence est trompeur — un chat vide sans explication. On le
// dit dans le voile ; il disparaîtra au {caught_up} de la reconnexion.
if(REPLAYING) setChatLoading('connexion au serveur…');
await new Promise(res=>setTimeout(res, 600));
}
}
// Interrompt la génération en cours côté serveur (la goroutine détachée est
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
// « network error » alarmiste alors que l'IA répond quand même.
async function send(){
if(busy) return;
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim();
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
if(!text && !ATTACH.length) return;
ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
addPending(text);
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
// on doit pouvoir en enlever une, et un échec doit rester visible.
const files=await attachPaths();
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
// Les pastilles passent dans la bulle en attente : le message porte ses
// fichiers dès l'envoi, sans attendre l'aller-retour.
if(PENDING) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){
try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
if(r.status===409 || r.ok) clearAttach();
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
if(r.ok) return; // la bulle + les tokens arrivent par le flux
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
}catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600));
}
// Après plusieurs échecs : le serveur a peut-être quand même reçu le message.
try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){}
fail('échec de l\'envoi — réessaie');
}
loadAll();
setInterval(loadStatus, 5000);
setInterval(loadVram, 3000);
setInterval(loadRam, 3000);
// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
// « llama.cpp précompilé » = binaires officiels, aucune compilation
// « llama.cpp compilé » = compilé ici, pour cette machine
// « llama.cpp personnalisé » = un fork (modèles à quant spéciale)
// Le CHOIX du moteur utilisé se fait par modèle (preset), pas ici — voir la
// section « Moteur » dans l'éditeur de modèle. Ça évite que la barre latérale
// et les presets se battent pour la ligne BIN.
let lcState = null, lcPoll = null, lcLogNext = 0;
async function loadLlamacpp(){
let s;
try{ s = await jget('/api/llamacpp'); }catch(_){ return; }
lcState = s;
const pb = s.prebuilt || {};
const fastInstalled = !!pb.bin;
const optInstalled = !!s.bin;
// Moteur fourni par l'image Docker : les trois modes d'installation n'ont
// aucun sens (rien à installer, aucun compilateur dans l'image). On affiche
// l'état réel à la place — les boutons du service, eux, restent utiles.
lcRenderProvided(s);
if(!s.provided){
lcRenderReco(s.reco);
lcRenderMode('fast', fastInstalled);
lcRenderMode('opt', optInstalled);
lcRenderCustomCard();
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
if(s.job && s.job.exists && s.job.running && !lcPoll){
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){
openDetails('lc-details');
document.getElementById('lc-job').style.display = '';
lcLogNext = 0;
document.getElementById('lc-log').textContent = '';
lcEndShown = true;
lcPollJob(true); // quiet : simple rattrapage, pas de toast ni de rechargement
}
lcChipSync(s.job);
}
// Préchauffe la liste des cartes du moteur actif : interroger le moteur prend
// 1 à 3 s (init CUDA/Vulkan), et sans ça l'encart « cartes graphiques » de
// l'éditeur apparaissait après coup, une fois le reste déjà affiché.
if(typeof prefetchGpuDevices === 'function') prefetchGpuDevices(s.config_bin);
}
// --- Pastille de rappel hors panneau ---------------------------------------
// Le détail de l'installation vit dans le panneau latéral, qui est un tiroir
// FERMÉ sur téléphone : après un rechargement, rien ne disait qu'une
// compilation tournait encore. La pastille le dit, et y ramène en un clic.
let lcSeenEnd = false, lcEndShown = false;
function lcChipLabel(action){
return {install:'Compilation du moteur', update:'Mise à jour 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');
if(!chip) return;
if(!j || !j.exists || (!j.running && (!j.error || lcSeenEnd))){ chip.hidden = true; return; }
chip.hidden = false;
chip.classList.toggle('failed', !j.running && !!j.error);
chip.textContent = j.running
? '⏳ ' + lcChipLabel(j.action) + ' — ' + (j.phase || '…')
: '✗ ' + lcChipLabel(j.action) + ' interrompue';
}
// Clic sur la pastille : ouvrir le tiroir sur la section Moteur.
function lcChipOpen(){
const side = document.getElementById('side');
if(!side.classList.contains('open')) toggleSide();
const det = document.getElementById('lc-details');
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;
lcChipSync(null);
}
// Place la pastille « conseillé » sur la carte que le SERVEUR recommande pour
// cette machine (voir recommendedMode) : le précompilé partout, sauf sur Linux
// avec une carte NVIDIA où il ne donnerait que du Vulkan. La raison est ajoutée
// à la description de la carte, pour expliquer plutôt que d'imposer.
function lcRenderReco(reco){
const mode = (reco && reco.mode) || 'fast';
for(const m of ['fast','opt']){
const badge = document.getElementById('lc-reco-'+m);
if(badge) badge.hidden = (m !== mode);
}
// La raison REMPLACE la description générique de la carte conseillée : elle
// dit déjà ce que fait l'option et pourquoi c'est le bon choix ici. (loadAll
// repasse par là, donc pas d'accumulation possible.)
const desc = document.getElementById('lc-desc-'+mode);
if(desc && reco && reco.why) desc.textContent = reco.why;
}
// 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');
const modes = document.querySelector('.lc-modes');
if(!box || !modes) return;
box.style.display = s.provided ? '' : 'none';
modes.style.display = s.provided ? 'none' : '';
if(!s.provided) return;
const bin = document.getElementById('lc-provided-bin');
if(bin) bin.textContent = s.config_bin || '';
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){
const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt');
const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state');
card.classList.toggle('installed', installed);
// Lien de vérification : interroge la dernière version SANS rien installer.
// event.stopPropagation empêche le clic de la carte (qui lance l'install).
const check = '<span class="lc-update-link" onclick="event.stopPropagation();lcCheck(\''+mode+'\')">vérifier la version</span>';
if(installed){
state.innerHTML = '<span class="lc-mode-active-tag">✓ installée</span>'
+ '<span class="lc-update-link" onclick="event.stopPropagation();lcUpdate(\''+mode+'\')">↻ mettre à jour</span>'
+ check;
} else {
state.innerHTML = '<span class="lc-mode-go">→ cliquer pour installer</span>' + check;
}
}
// lcCheck vérifie s'il existe une version plus récente AVANT toute installation
// (endpoints de check dédiés, sans effet de bord). Résultat affiché en toast.
async function lcCheck(mode){
toast('vérification…');
try{
if(mode === 'fast'){
const r = await jpost('/api/llamacpp/prebuilt/check', {});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
if(r.update) toast('nouvelle version disponible : '+r.latest+(r.current ? ' (installée : '+r.current+')' : ''));
else toast('llama.cpp précompilé à jour ✓'+(r.latest ? ' ('+r.latest+')' : ''));
} else {
const r = await jpost('/api/llamacpp/check', {});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
if(r.behind > 0) toast(r.behind+' nouveau(x) commit(s) disponible(s) — utilisez « mettre à jour »');
else toast('llama.cpp compilé à jour ✓');
}
}catch(_){ toast('erreur réseau'); }
}
// Clic sur une carte : installer le moteur (s'il ne l'est pas déjà).
async function lcPick(mode){
const s = lcState || {}, pb = s.prebuilt || {};
const installed = mode==='fast' ? !!pb.bin : !!s.bin;
if(installed){
toast('déjà installée — choisissez-la dans l\'édition d\'un modèle (⚙)');
return;
}
if(mode === 'fast'){
if(!await askConfirm('Télécharger le binaire officiel de llama.cpp, prêt à l\'emploi (~2 min, aucune compilation).', {title:'llama.cpp précompilé', okText:'Installer'})) return;
const r = await jpost('/api/llamacpp/prebuilt', {});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
} else {
if(!await askConfirm('Compiler llama.cpp pour votre machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA).', {title:'llama.cpp compilé', okText:'Compiler'})) return;
const r = await jpost('/api/llamacpp/install', {});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
}
lcStartPolling();
}
// « Mettre à jour » sur une carte installée.
async function lcUpdate(mode){
if(mode === 'fast'){
if(!await askConfirm('Vérifier et installer le dernier binaire précompilé.', {title:'Mettre à jour', okText:'Mettre à jour'})) return;
const r = await jpost('/api/llamacpp/prebuilt', {});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
} else {
if(!await askConfirm('Vérifier et installer la dernière version compilée (recompilation si besoin).', {title:'Mettre à jour', okText:'Mettre à jour'})) return;
const r = await jpost('/api/llamacpp/update', {clean:false});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
}
lcStartPolling();
}
// --- Backends personnalisés (3e carte + modal de gestion) ------------------
let lcCustomBackends = [];
// État affiché sur la carte « Backend personnalisé » : nombre d'installés.
async function lcRenderCustomCard(){
const state = document.getElementById('lc-custom-state');
if(!state) return;
try{ lcCustomBackends = await jget('/api/backends/custom') || []; }catch(_){ lcCustomBackends = []; }
const n = lcCustomBackends.length;
document.getElementById('lc-mode-custom').classList.toggle('installed', n>0);
state.innerHTML = n>0
? '<span class="lc-mode-active-tag">✓ '+n+' installé'+(n>1?'s':'')+'</span><span class="lc-mode-go">→ gérer</span>'
: '<span class="lc-mode-go">→ voir / installer</span>';
}
function openCustomBackends(){
showModal('lc-custom-modal');
document.getElementById('lc-custom-url').value = '';
document.getElementById('lc-custom-name').value = '';
loadCustomBackends();
}
function closeCustomBackends(){ hideModal('lc-custom-modal'); }
// Liste les backends custom dans le modal, avec un bouton supprimer par ligne.
async function loadCustomBackends(){
const box = document.getElementById('lc-custom-list');
box.innerHTML = '<span class="muted" style="font-size:12px">chargement…</span>';
let list = [];
try{ list = await jget('/api/backends/custom') || []; }catch(_){}
lcCustomBackends = list;
if(!list.length){ box.innerHTML = '<span class="muted" style="font-size:12px">aucun backend personnalisé pour l\'instant.</span>'; return; }
box.innerHTML = list.map(b=>{
const nm = String(b.name).replace(/[<>&]/g,'');
const used = b.in_use ? '<span class="mcp-tag" style="border-color:var(--accent);color:var(--accent)">utilisé</span>' : '';
return '<div class="mcp-row" style="cursor:default">'
+ '<span class="mcp-dot '+(b.in_use?'mcp-dot-ok':'mcp-dot-off')+'"></span>'
+ '<div class="mcp-info"><div class="mcp-name">'+nm+'</div>'
+ '<div class="mcp-meta">'+used+'<span class="mcp-tag" title="'+String(b.path).replace(/"/g,'&quot;')+'">'+String(b.path).split('/').slice(-3).join('/').replace(/[<>&]/g,'')+'</span></div></div>'
+ '<button class="btn-danger" style="padding:3px 8px;font-size:11px" onclick="lcUninstallCustom(\''+nm.replace(/'/g,"\\'")+'\')">supprimer</button>'
+ '</div>';
}).join('');
}
// Installer un backend CUSTOM depuis une URL de dépôt Git (fork llama.cpp).
// Cloné + compilé dans backends/<nom>, SANS toucher au moteur global : il
// apparaît ensuite dans le menu « backend détecté » de l'éditeur de modèle.
async function lcInstallCustom(){
const url = (document.getElementById('lc-custom-url').value||'').trim();
if(!url){ toast('collez l\'URL d\'un dépôt Git'); return; }
const name = (document.getElementById('lc-custom-name').value||'').trim();
if(!await askConfirm('Cloner et compiler ce backend depuis :\n'+url+'\n\nCela peut prendre de longues minutes (surtout avec une carte NVIDIA). Il ne remplace pas le moteur global — vous le choisirez par modèle.', {title:'Backend personnalisé', okText:'Installer'})) return;
const r = await jpost('/api/llamacpp/install-custom', {repo:url, name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
closeCustomBackends();
openDetails('lc-details');
lcStartPolling();
}
// Désinstaller (supprime le dossier backends/<name>). Le serveur refuse si le
// backend sert de moteur au modèle actif.
async function lcUninstallCustom(name){
if(!await askConfirm('Supprimer le backend « '+name+' » ? Son dossier compilé sera effacé. Les modèles qui l\'utilisent devront être repointés sur un autre moteur.', {title:'Supprimer le backend', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/llamacpp/uninstall-custom', {name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
toast('backend supprimé');
loadCustomBackends();
lcRenderCustomCard();
}
// --- Progression de l'installation (téléchargement / compilation) ----------
function lcBusy(on){ document.querySelector('.lc-modes').classList.toggle('busy', on); }
function lcStartPolling(){
document.getElementById('lc-job').style.display = '';
document.getElementById('lc-log').textContent = '';
lcLogNext = 0;
lcSeenEnd = false; lcEndShown = false;
lcBusy(true);
if(lcPoll) clearInterval(lcPoll);
lcPoll = setInterval(lcPollJob, 1000);
lcPollJob();
}
async function lcPollJob(quiet){
let j;
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
if(!j.exists) return;
const phaseEl = document.getElementById('lc-job-phase');
if(j.lines && j.lines.length){
const pre = document.getElementById('lc-log');
const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20;
pre.textContent += j.lines.join('\n') + '\n';
if(stick) pre.scrollTop = pre.scrollHeight;
}
if(typeof j.next === 'number') lcLogNext = j.next;
lcChipSync(j);
if(j.running){
phaseEl.innerHTML = '<span class="lc-spin">⏳</span> <span>'+String(j.phase||'…').replace(/[<>&]/g,'')+'</span>';
return;
}
if(lcPoll){ clearInterval(lcPoll); lcPoll = null; }
lcBusy(false);
if(j.error){
phaseEl.innerHTML = '<span style="color:var(--err)">✗ '+String(j.error).replace(/[<>&]/g,'')+'</span>';
const pre = document.getElementById('lc-log');
if(pre.hasAttribute('hidden')) lcToggleLog();
pre.scrollTop = pre.scrollHeight;
toast('échec — voir les détails');
} else {
phaseEl.innerHTML = '<span style="color:var(--ok)">✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+'</span>';
toast('c\'est prêt ✓');
}
loadAll();
}
function lcToggleLog(){
const pre = document.getElementById('lc-log');
const bar = document.querySelector('.lc-logbar');
if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; }
else { pre.setAttribute('hidden',''); bar.classList.remove('open'); }
}
// ─── Serveurs MCP ────────────────────────────────────────────────────────────
// Gestion des serveurs MCP (Model Context Protocol) : liste, ajout/édition via
// une modale, activation/désactivation, test de connexion. Sous-réglage du mode
// agent (grisé quand l'agent est off, comme mémoire/internet).
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
function renderMCP(r){
mcpServers = (r && r.servers) || [];
// Pas de pastille de comptage : chaque serveur affiche déjà son propre état
// dans la liste juste en dessous.
setBadge('mcp-badge', null);
const list = document.getElementById('mcp-list');
list.textContent = '';
if(!mcpServers.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div>';
return;
}
mcpServers.forEach(s=>{
const row = document.createElement('div');
row.className = 'mcp-row'+(s.enabled?'':' off');
// Pastille d'état : bleu accent = connecté, rouge = erreur, gris = éteint.
const dot = document.createElement('span');
dot.className = 'mcp-dot';
if(!s.enabled) dot.classList.add('mcp-dot-off');
else if(s.connected) dot.classList.add('mcp-dot-ok');
else dot.classList.add('mcp-dot-err');
dot.title = !s.enabled ? 'désactivé' : (s.connected ? 'connecté' : 'erreur de connexion');
const info = document.createElement('div');
info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name';
nm.textContent = s.name; nm.title = s.name;
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const tr = document.createElement('span'); tr.className = 'mcp-tag';
tr.textContent = s.transport || '?'; meta.appendChild(tr);
if(s.enabled && s.connected){
const total = s.tools ? s.tools.length : 0;
const off = s.disabled ? s.disabled.length : 0;
const active = total - off;
const tc = document.createElement('span'); tc.className = 'mcp-tools';
tc.textContent = off ? (active+'/'+total+' outils') : (total+' outil'+(total>1?'s':''));
if(s.tools && s.tools.length) tc.title = s.tools.join(', ');
meta.appendChild(tc);
} else if(s.enabled && s.error){
const er = document.createElement('span'); er.className = 'mcp-err';
er.textContent = 'erreur'; er.title = s.error;
meta.appendChild(er);
}
info.appendChild(nm); info.appendChild(meta);
// Interrupteur activer/désactiver. stopPropagation sur le label ENTIER (pas
// seulement la checkbox) : sinon un clic sur le slider remonte à la ligne et
// ouvre la modale en même temps que le toggle.
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
sw.onclick = (e)=>e.stopPropagation();
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!s.enabled;
cb.onchange = ()=>toggleMcp(s.name, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const edit = document.createElement('button');
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);
row.onclick = ()=>openMcp(s.name);
list.appendChild(row);
});
}
async function toggleMcp(name, on){
renderMCP(await jpost('/api/mcp/toggle', {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 || '';
const s = name ? mcpServers.find(x=>x.name===name) : null;
document.getElementById('mcp-modal-title').textContent = s ? 'Éditer « '+s.name+' »' : 'Nouveau serveur MCP';
document.getElementById('mcp-name').value = s ? s.name : '';
document.getElementById('mcp-name').disabled = !!s; // le nom identifie le serveur, pas de renommage
const transport = s ? (s.transport||'stdio') : 'stdio';
document.querySelector('input[name="mcp-transport"][value="'+transport+'"]').checked = true;
mcpTransportUI(transport);
document.getElementById('mcp-command').value = s ? (s.command||'') : '';
document.getElementById('mcp-args').value = s && s.args ? s.args.join('\n') : '';
document.getElementById('mcp-env').value = s && s.env ? Object.entries(s.env).map(([k,v])=>k+'='+v).join('\n') : '';
document.getElementById('mcp-url').value = s ? (s.url||'') : '';
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠ '+s.error; st.style.color = 'var(--err)'; }
else if(s && s.connected){ st.innerHTML = '<span style="color:var(--accent)">✓</span> connecté — '+(s.tools?s.tools.length:0)+' outil(s)'; }
document.getElementById('mcp-del').style.display = s ? '' : 'none';
renderMcpTools(s);
showModal('mcp-modal');
}
// Liste des outils découverts du serveur, avec une case par outil pour le
// masquer/exposer à l'IA. Visible seulement pour un serveur connecté.
function renderMcpTools(s){
const sec = document.getElementById('mcp-tools-section');
const list = document.getElementById('mcp-tools-list');
list.textContent = '';
if(!s || !s.connected || !s.tools || !s.tools.length){ sec.style.display = 'none'; return; }
sec.style.display = '';
const off = new Set(s.disabled || []);
document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)';
s.tools.slice().sort().forEach(tool=>{
const row = document.createElement('label'); row.className = 'mcp-tool-row';
const sw = document.createElement('span'); sw.className = 'switch mcp-tool-switch';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !off.has(tool);
cb.onchange = ()=>toggleMcpTool(s.name, tool, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const nm = document.createElement('code'); nm.className = 'mcp-tool-name'; nm.textContent = tool;
row.appendChild(sw); row.appendChild(nm);
list.appendChild(row);
});
}
async function toggleMcpTool(server, tool, on){
const r = await jpost('/api/mcp/tool', {name: server, tool, on});
renderMCP(r);
// Rafraîchit le compteur de la modale à partir de l'état à jour.
const s = (r.servers||[]).find(x=>x.name===server);
const off = s && s.disabled ? new Set(s.disabled) : new Set();
if(s && s.tools) document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)';
}
function closeMcp(){ hideModal('mcp-modal'); mcpEditing = null; }
function mcpTransportUI(t){
document.getElementById('mcp-stdio-fields').style.display = t==='stdio' ? '' : 'none';
document.getElementById('mcp-http-fields').style.display = t==='http' ? '' : 'none';
}
// Parse un textarea "KEY<sep>valeur" par ligne → objet. sep = '=' (env) ou ':' (headers).
function mcpParseKV(text, sep){
const out = {};
(text||'').split('\n').forEach(line=>{
const s = line.trim();
if(!s) return;
const i = s.indexOf(sep);
if(i <= 0) return;
out[s.slice(0,i).trim()] = s.slice(i+1).trim();
});
return Object.keys(out).length ? out : undefined;
}
async function saveMcp(){
const name = document.getElementById('mcp-name').value.trim();
if(!name){ toast('donne un nom au serveur'); return; }
const transport = document.querySelector('input[name="mcp-transport"]:checked').value;
const body = {name};
if(transport==='stdio'){
body.command = document.getElementById('mcp-command').value.trim();
if(!body.command){ toast('renseigne la commande'); return; }
body.args = document.getElementById('mcp-args').value.split('\n').map(x=>x.trim()).filter(Boolean);
body.env = mcpParseKV(document.getElementById('mcp-env').value, '=');
body.url = ''; body.headers = undefined;
} else {
body.url = document.getElementById('mcp-url').value.trim();
if(!body.url){ toast("renseigne l'URL"); return; }
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
if(!r.ok){ st.textContent = '⚠ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; }
renderMCP(r);
const saved = (r.servers||[]).find(x=>x.name===name);
if(saved && saved.connected){ toast('serveur connecté — '+(saved.tools?saved.tools.length:0)+' outil(s)'); closeMcp(); }
else if(saved && saved.error){ st.textContent = '⚠ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; }
else { toast('enregistré'); closeMcp(); }
}
async function deleteMcp(){
if(!mcpEditing) return;
if(!await askConfirm('Retirer le serveur « '+mcpEditing+' » ? Ses outils ne seront plus proposés à l\'IA.', {title:'Supprimer le serveur MCP ?', okText:'Supprimer', danger:true})) return;
await jpost('/api/mcp/delete', {name: mcpEditing});
closeMcp();
loadMCP();
toast('serveur supprimé');
}
// ─── Intensité du raisonnement (barre de saisie) ─────────────────────────────
// Le même réglage existe dans l'éditeur de preset, mais il y est figé avec le
// preset : le changer demande d'ouvrir les réglages, éditer, enregistrer. Ici il
// s'applique au message suivant, sans redémarrer le moteur — `reasoning_effort`
// voyage dans le corps de la requête, pas dans la ligne de commande.
//
// La valeur part dans la config vivante. Appliquer un preset la réécrit : le
// réglage reste donc attaché au modèle, cette liste ne fait que le dévier à la
// volée.
const THINK_EFFORT_LABELS = {
'': 'auto',
none: 'aucune',
low: 'basse',
medium: 'moyenne',
high: 'haute',
xhigh: 'maximale',
};
async function loadThinkEffort(){
try{ applyThinkEffort(await jget('/api/reasoning-effort')); }
catch(e){ /* endpoint indisponible : la liste garde sa valeur par défaut */ }
}
function applyThinkEffort(r){
const sel = document.getElementById('think-effort');
if(!sel || !r || !r.ok) return;
sel.value = r.value || '';
// Raisonnement coupé au niveau du preset : le niveau n'a plus de prise. On
// grise plutôt que de masquer, pour que le réglage reste trouvable.
sel.disabled = !r.on;
sel.title = r.on
? 'Intensité du raisonnement — envoyée au gabarit du modèle'
: 'Raisonnement désactivé pour ce modèle (réglages → Raisonnement)';
}
async function setThinkEffort(value){
const r = await jpost('/api/reasoning-effort', {value});
if(!r || !r.ok){ toast((r && r.error) || 'réglage refusé'); return; }
applyThinkEffort(r);
toast('raisonnement : '+(THINK_EFFORT_LABELS[r.value||''] || r.value));
}
// ===== Pièces jointes du chat ==============================================
// Un fichier joint n'est PAS lu par le navigateur ni injecté dans le contexte :
// il est déposé tel quel dans le dossier de travail de l'IA (uploads/, côté
// serveur), et seul son chemin est annoncé dans le message. Le modèle l'ouvre
// s'il en a besoin, avec ses outils — ce qui marche pour n'importe quel type de
// fichier sans que l'UI ait à en connaître le format.
//
// Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la
// seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant
// fonctionne sans code particulier.
// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go).
// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent
// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
const ATTACH_MAX = 1024*1024*1024;
const ATTACH_CHUNK = 8*1024*1024;
// Délai par MORCEAU (et pas les 30 s d'un appel /api/* ordinaire) : une tranche de
// 8 Mo, base64 + tunnel E2E, peut dépasser 30 s sur une liaison lente.
const UPLOAD_CHUNK_TIMEOUT_MS = 120000;
let ATTACH = [];
let ATTACH_SEQ = 0;
function fmtSize(n){
if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go';
if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo';
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
}
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
// une vignette cassée qui retombe sur son texte de repli.
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
// objet visuel des deux côtés, seul le contexte (CSS) change.
//
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
// vers le serveur à ce stade).
function fileChip(name, size, opts){
opts=opts||{};
const chip=document.createElement('div');
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
if(opts.thumb){
const t=document.createElement('img');
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
chip.appendChild(t);
chip.classList.add('has-thumb');
}
const n=document.createElement('span');
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
const s=document.createElement('span');
s.className='cf-size'; s.textContent=opts.sizeText||fmtSize(size);
chip.appendChild(n); chip.appendChild(s);
if(opts.onRemove){
const x=document.createElement('button');
x.type='button'; x.textContent='×'; x.title='retirer';
x.setAttribute('aria-label','retirer '+name);
x.onclick=opts.onRemove;
chip.appendChild(x);
}
return chip;
}
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
// un bloc bâtard, moitié texte moitié pastilles.
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
// balise <img> ne peut pas porter la clé de pilotage.
function msgImgTile(f){
const box=document.createElement('div');
box.className='msg-img';
const img=document.createElement('img');
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
img.alt=f.name||''; img.title=f.name||'';
box.appendChild(img);
return box;
}
function addMsgFiles(el, files){
if(!el || !files || !files.length) return;
const box=document.createElement('div');
box.className='msg-files';
for(const f of files){
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
// apprendre — le nom vit dans l'infobulle et dans la loupe.
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
}
el.parentNode.insertBefore(box, el);
hydrateImages(box);
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
markEmptyMsg(el);
return box;
}
// Une bulle sans texte n'a rien à montrer — seules ses pièces jointes parlent.
function markEmptyMsg(el){
const b=el&&el.querySelector('.body');
if(b) el.classList.toggle('empty', b.textContent==='');
}
// La rangée de fichiers précède la bulle : c'est là qu'on vérifie sa présence
// avant d'en ajouter une seconde (replay + bulle en attente).
function hasMsgFiles(el){
const p=el&&el.previousElementSibling;
return !!(p&&p.classList.contains('msg-files'));
}
// ===== Fichiers remis par l'IA ============================================
// Aucun outil, aucune syntaxe maison : l'IA écrit un lien Markdown ordinaire
// vers un fichier de son dossier de travail — [Télécharger](rapport.pdf) — et
// c'est l'UI qui le transforme en téléchargement. Le modèle n'a rien à savoir
// de plus que ce qu'il fait déjà naturellement.
//
// Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête
// Authorization que le navigateur ne mettrait pas sur une navigation, et le
// chemin de base change derrière le tunnel (/u/<id>). D'où le fetch + blob.
const DL_CHUNK = 8*1024*1024; // aligné sur downloadChunkMax (web_upload.go)
// Décode une tranche base64 en octets.
function b64ToBytes(s){
const bin=atob(s), out=new Uint8Array(bin.length);
for(let i=0;i<bin.length;i++) out[i]=bin.charCodeAt(i);
return out;
}
// Récupère le fichier en tranches base64. C'est la voie OBLIGATOIRE derrière
// app.ajean.link : le proxy chiffré réemballe toute réponse en JSON, et du
// binaire n'y survit pas — on téléchargeait l'enveloppe JSON à la place du
// fichier. Le base64, lui, traverse intact.
async function fetchFileB64(path, size, chip){
const parts=[]; let off=0;
do{
const r=await jfetch('/api/chat/file?b64=1&len='+DL_CHUNK+'&offset='+off+'&path='+encodeURIComponent(path));
const j=await r.json();
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
const bytes=b64ToBytes(j.data||'');
parts.push(bytes);
// Une tranche vide sans marque de fin ne ferait pas avancer la boucle : on
// s'arrête plutôt que de tourner indéfiniment sur le même octet.
if(!bytes.length && !j.eof) throw new Error('transfert interrompu');
off=(j.offset||0)+bytes.length;
if(chip && size) chip.title='téléchargement '+Math.round(off*100/size)+' %';
if(j.eof) break;
} while(off<size);
return new Blob(parts);
}
async function downloadWorkspaceFile(path, name, a){
if(a) a.classList.add('busy');
const wasTitle=a?a.title:'';
try{
// La fiche d'abord : elle est minuscule, elle passe partout, et elle dit si
// on est derrière le tunnel — ce que le client ne peut pas deviner seul, le
// proxy lui rendant des réponses JSON parfaitement ordinaires.
const m=await jfetch('/api/chat/file?meta=1&path='+encodeURIComponent(path));
const meta=await m.json().catch(()=>({}));
if(!m.ok || !meta.ok){
toast('téléchargement impossible : '+(meta.error||('HTTP '+m.status))); return;
}
let blob;
if(meta.e2e){
blob=await fetchFileB64(path, meta.size||0, a);
} else {
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
if(!r.ok){
let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){}
toast('téléchargement impossible : '+msg); return;
}
blob=await r.blob();
}
const blobURL=URL.createObjectURL(blob);
const link=document.createElement('a');
link.href=blobURL; link.download=name||meta.name||'fichier';
document.body.appendChild(link); link.click(); link.remove();
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
// dans la foulée du clic.
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
}catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); }
finally{ if(a){ a.classList.remove('busy'); a.title=wasTitle; } }
}
// Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle
// écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un
// lien mort. Or les noms de fichiers à espaces sont la règle, pas l'exception. On
// les encode donc avant le rendu (fileLinkPath les redécode).
//
// Les cibles avec un titre — [x](chemin "Titre") — sont laissées tranquilles :
// leurs espaces sont syntaxiques.
function encodeMdLinkSpaces(text){
return String(text).replace(/\]\(([^)\n]*)\)/g, (whole, target)=>{
if(target.indexOf(' ')<0 || /["']/.test(target)) return whole;
if(/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return whole; // URL : pas notre affaire
return '](' + target.replace(/ /g, '%20') + ')';
});
}
// Chemin visé par un lien de la réponse, ramené au dossier de travail — ou ''
// si le lien pointe ailleurs (page web, ancre, mailto…).
//
// Le modèle écrit indifféremment un chemin relatif, un chemin absolu du serveur,
// ou le préfixe `sandbox:` qu'il a vu ailleurs : les trois doivent marcher, on
// ne va pas lui demander d'être rigoureux sur un détail pareil.
function fileLinkPath(href){
if(!href) return '';
let h=href.trim();
if(/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(h)) return '';
h=h.replace(/^sandbox:/i,'').replace(/^file:\/\//i,'');
try{ h=decodeURI(h); }catch(_){}
h=h.replace(/\\/g,'/');
// Chemin absolu : on ne garde que ce qui suit le dossier de travail. Le serveur
// revérifie de toute façon — c'est lui qui fait autorité sur le périmètre.
const m=h.match(/(?:^|\/)workspace\/(.+)$/);
if(m) h=m[1];
h=h.replace(/^\/+/,'').replace(/^\.\//,'');
if(!h || h.indexOf('..')>=0) return '';
return h;
}
// Transforme en téléchargements les liens d'une réponse qui visent un fichier.
function markFileLinks(root){
for(const a of root.querySelectorAll('a[href]')){
const p=fileLinkPath(a.getAttribute('href'));
if(!p) continue;
a.classList.add('filelink');
a.removeAttribute('target');
a.setAttribute('href','#');
const name=p.split('/').pop();
a.title='télécharger '+name;
a.onclick=(e)=>{ e.preventDefault(); downloadWorkspaceFile(p, name, a); };
}
}
function attachListEl(){ return document.getElementById('attach-list'); }
function renderAttach(){
const el = attachListEl(); if(!el) return;
el.innerHTML='';
el.classList.toggle('show', ATTACH.length>0);
for(const a of ATTACH){
el.appendChild(fileChip(a.name, a.size, {
cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''),
title: a.error || a.name,
// Un gros fichier prend du temps : on montre l'avancement plutôt qu'un
// anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre.
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
thumb: a.thumb,
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
}));
}
}
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
function dropThumb(a){
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
}
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
// Ce qui part avec le message, pour l'afficher dans sa bulle.
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
// Lecture en base64. readAsDataURL préfixe par "data:<mime>;base64," — le
// serveur sait retirer cet en-tête, on n'a donc rien à découper ici.
function readAsBase64(file){
return new Promise((res,rej)=>{
const fr=new FileReader();
fr.onload=()=>res(String(fr.result||''));
fr.onerror=()=>rej(new Error('lecture impossible'));
fr.readAsDataURL(file);
});
}
// Envoie UN morceau. VIA jfetch, pas un fetch brut : derrière app.ajean.link,
// jfetch est réécrit pour traverser la boîte noire E2E — un POST direct vers
// /api/chat/upload n'atteint PAS ton serveur (le relais est aveugle) et l'envoi
// échouait « impossible d'envoyer ». On fournit notre PROPRE signal pour ne pas
// hériter du plafond de 30 s de jfetch (calibré pour de petits appels), un morceau
// pouvant être plus long ; le download passe déjà par jfetch, on symétrise.
async function sendChunk(payload){
const ac=new AbortController();
const timer=setTimeout(()=>ac.abort(), UPLOAD_CHUNK_TIMEOUT_MS);
try{
const r=await jfetch('/api/chat/upload',{
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload), signal:ac.signal
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
} finally { clearTimeout(timer); }
}
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
// un fichier de la liste ne laisse donc aucune trace sur le disque.
//
// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois
// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe
// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on
// en est, ce qu'un envoi monolithique ne permettait pas.
async function uploadAttach(a){
a.state='up'; a.sent=0; renderAttach();
try{
let id='';
// `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand
// même faire un tour de boucle, sinon rien ne part.
let off=0;
do{
const end=Math.min(off+ATTACH_CHUNK, a.size);
const data=await readAsBase64(a.file.slice(off, end));
const last=end>=a.size;
// `size` au premier morceau : le serveur vérifie la place disque AVANT
// d'entamer un envoi d'un gigaoctet, plutôt que de le découvrir à la fin.
const j=await sendChunk({name:a.name, data:data, id:id, more:!last, size:(id?0:a.size)});
if(j.id) id=j.id;
off=end;
a.sent=off; renderAttach();
if(last){ a.path=j.path; }
} while(off<a.size);
a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
}catch(e){
a.state='err'; a.error=(e&&e.message)||'échec';
toast('« '+a.name+' » : '+a.error);
}
renderAttach();
}
function addFiles(files){
for(const f of files||[]){
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
// source d'image disponible à ce stade.
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
}
renderAttach();
}
function onAttachPick(e){
addFiles(e.target.files);
e.target.value=''; // sinon re-choisir le même fichier ne déclenche pas change
}
// Coller un fichier (capture d'écran, fichier copié depuis l'explorateur). On ne
// touche à rien quand le presse-papier ne contient que du texte : le collage
// normal dans la zone de saisie doit rester intact.
function onPaste(e){
const items=(e.clipboardData&&e.clipboardData.files)||[];
if(!items.length) return;
e.preventDefault();
addFiles(items);
}
// Glisser-déposer sur toute la fenêtre. Le compteur de profondeur est nécessaire :
// dragenter/dragleave se déclenchent aussi au passage d'un enfant à l'autre, et
// sans lui le voile clignote dès que le curseur traverse une bulle du fil.
let DRAG_DEPTH=0;
function dropVeil(on){
const v=document.getElementById('dropveil'); if(v) v.classList.toggle('show', on);
}
function hasFiles(e){
const dt=e.dataTransfer; if(!dt) return false;
if(dt.types) for(const t of dt.types){ if(t==='Files') return true; }
return false;
}
window.addEventListener('dragenter', e=>{ if(!hasFiles(e)) return; e.preventDefault(); DRAG_DEPTH++; dropVeil(true); });
window.addEventListener('dragover', e=>{ if(hasFiles(e)) e.preventDefault(); });
window.addEventListener('dragleave', e=>{ if(!hasFiles(e)) return; DRAG_DEPTH=Math.max(0,DRAG_DEPTH-1); if(!DRAG_DEPTH) dropVeil(false); });
window.addEventListener('drop', e=>{
if(!hasFiles(e)) return;
e.preventDefault(); DRAG_DEPTH=0; dropVeil(false);
addFiles(e.dataTransfer.files);
});
// Appelé par send() : c'est ICI que les fichiers partent réellement vers le
// serveur. Ceux qui échouent sont ignorés — leur pastille reste à l'écran en
// rouge, elle dit déjà ce qui s'est passé.
async function attachPaths(){
await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach));
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
}
renderAttach();
// ─── 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).
//
// 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 à 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
// souvent le panneau ouvert, et le rouvrir à chaque rechargement est pénible.
function toggleFiles(force){
FILES_OPEN = (force === undefined) ? !FILES_OPEN : !!force;
document.documentElement.setAttribute('data-files', FILES_OPEN ? '1' : '0');
const btn = document.getElementById('files-btn');
if(btn) btn.setAttribute('aria-expanded', FILES_OPEN ? 'true' : 'false');
try{ localStorage.setItem('loki-files-open', FILES_OPEN ? '1' : '0'); }catch(_){}
// Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` :
// la gouttière de barre de défilement mesurée dans --sbw resterait celle de
// l'ancienne largeur, et la carte de saisie serait décalée du fil. La hauteur,
// elle, est suivie par un ResizeObserver et n'a besoin de rien.
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
if(FILES_OPEN) loadFiles();
}
// Restaure l'état à l'ouverture de la page. Sur petit écran le panneau est un
// tiroir qui masque toute la conversation : on ne le rouvre jamais tout seul.
function initFiles(){
let want = false;
try{ want = localStorage.getItem('loki-files-open') === '1'; }catch(_){}
toggleFiles(want && window.innerWidth > 720);
}
async function loadFiles(dir){
if(dir !== undefined) FILES_DIR = dir;
const list = document.getElementById('files-list');
const foot = document.getElementById('files-foot');
if(!list) return;
let r;
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
// onglet) ne doit pas laisser le panneau bloqué : on remonte à la racine.
if(FILES_DIR){ FILES_DIR = ''; return loadFiles(); }
filesMsg(r.error || 'lecture impossible'); return;
}
renderCrumb(r);
list.textContent = '';
if(!r.entries.length){
const d = document.createElement('div');
d.className = 'files-empty muted';
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));
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 || '';
// Tout emporter d'un coup : la discussion entière (dossiers et sous-dossiers
// compris) en une archive .zip. Absent quand il n'y a rien à archiver.
if(r.count > 0){
const z = document.createElement('button');
z.className = 'files-scope';
z.textContent = '.zip';
z.title = 'tout télécharger en une archive .zip';
z.onclick = ()=>downloadFilesZip(z);
foot.appendChild(z);
}
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){
const list = document.getElementById('files-list');
if(!list) return;
list.textContent = '';
const d = document.createElement('div');
d.className = 'files-empty'; d.style.color = 'var(--err)'; d.textContent = txt;
list.appendChild(d);
}
// 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 = '';
const seg = (label, dir, last)=>{
const b = document.createElement('button');
b.className = 'files-crumb-b' + (last ? ' on' : '');
b.textContent = label;
if(!last) b.onclick = ()=>loadFiles(dir);
else b.disabled = true;
c.appendChild(b);
};
const parts = r.dir ? r.dir.split('/') : [];
seg(FILES_SCOPE === 'legacy' ? 'hors discussion' : 'discussion', '', parts.length === 0);
let acc = '';
parts.forEach((p, i)=>{
acc = acc ? acc + '/' + p : p;
const sep = document.createElement('span');
sep.className = 'files-crumb-s'; sep.textContent = '/';
c.appendChild(sep);
seg(p, acc, i === parts.length - 1);
});
}
function fileRow(e){
const row = document.createElement('div');
row.className = 'files-row' + (e.dir ? ' isdir' : '');
const ic = document.createElement('span');
ic.className = 'files-ic';
ic.appendChild(icon(e.dir ? 'folder' : (e.image ? 'image' : 'file'), 16));
row.appendChild(ic);
const mid = document.createElement('span');
mid.className = 'files-mid';
const nm = document.createElement('span');
nm.className = 'files-name';
nm.textContent = e.name;
nm.title = e.name;
const meta = document.createElement('span');
meta.className = 'files-meta';
meta.textContent = fmtSize(e.size) + (e.dir && e.items ? ' · ' + e.items + ' fich.' : '') + ' · ' + fmtWhen(e.mod);
mid.append(nm, meta);
row.appendChild(mid);
// Un dossier s'ouvre ; un fichier se télécharge. Le clic sur la ligne fait
// l'action évidente, les boutons ne servent qu'au reste.
if(e.dir){
mid.onclick = ()=>loadFiles(e.path);
mid.style.cursor = 'pointer';
} else {
mid.onclick = ()=>downloadWorkspaceFile(e.path, e.name, row);
mid.style.cursor = 'pointer';
}
const acts = document.createElement('span');
acts.className = 'files-acts';
if(!e.dir){
const dl = document.createElement('button');
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.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);
return row;
}
// Suppression. Toujours confirmée, et l'invite dit ce qui part : un dossier
// emporte tout son contenu, ce qu'un nom seul ne laisse pas deviner.
async function removeFile(e){
const quoi = e.dir
? 'Le dossier « '+e.name+' » et tout son contenu ('+(e.items||0)+' fichiers, '+fmtSize(e.size)+') seront supprimés.'
: '« '+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, scope: FILES_SCOPE});
if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
loadFiles();
}
// Télécharge TOUT le contenu affiché (discussion ou hors-discussion) en .zip.
// Même danse que downloadWorkspaceFile (14-attach.js) : la fiche meta d'abord —
// c'est elle qui CONSTRUIT l'archive côté serveur — puis le brut en direct, ou
// les tranches base64 derrière le tunnel chiffré.
async function downloadFilesZip(btn){
if(btn){ btn.disabled = true; btn.textContent = '…'; }
try{
const m = await jfetch('/api/chat/files/zip?meta=1&scope='+encodeURIComponent(FILES_SCOPE));
const meta = await m.json().catch(()=>({}));
if(!m.ok || !meta.ok){ toast('archive impossible : '+(meta.error||('HTTP '+m.status))); return; }
let blob;
if(meta.e2e){
blob = await fetchZipB64(meta.size||0);
} else {
const r = await jfetch('/api/chat/files/zip?scope='+encodeURIComponent(FILES_SCOPE));
if(!r.ok){ let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} toast('archive impossible : '+msg); return; }
blob = await r.blob();
}
const u = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = u; a.download = meta.name || 'loki-fichiers.zip';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(u), 10000);
toast(meta.files + ' fichier'+(meta.files>1?'s':'')+' archivé'+(meta.files>1?'s':''));
}catch(e){ toast('archive impossible : '+((e&&e.message)||'erreur')); }
finally{ if(btn){ btn.disabled = false; btn.textContent = '.zip'; } }
}
// Tranches base64 du zip (tunnel chiffré) — cousin de fetchFileB64.
async function fetchZipB64(size){
const parts = [];
let off = 0;
do{
const r = await jfetch('/api/chat/files/zip?b64=1&len='+DL_CHUNK+'&offset='+off+'&scope='+encodeURIComponent(FILES_SCOPE));
const j = await r.json();
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
const bytes = b64ToBytes(j.data||'');
parts.push(bytes);
if(!bytes.length && !j.eof) throw new Error('transfert interrompu');
off = (j.offset||0) + bytes.length;
if(j.eof) break;
} while(off < size);
return new Blob(parts, {type:'application/zip'});
}
// 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);
function fmtWhen(sec){
if(!sec) return '';
const d = new Date(sec*1000), now = new Date();
const memeJour = d.toDateString() === now.toDateString();
return memeJour
? d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'})
: d.toLocaleDateString(undefined, {day:'2-digit', month:'2-digit'});
}
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
//
// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils :
// basculer ici bascule partout. Le changement arrive donc par le flux SSE (un
// événement `reset`, comme pour « vider »), et c'est lui qui redessine le chat —
// on ne fait que rafraîchir la liste.
//
// 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 = '';
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
// route, ou ouverte depuis un autre appareil).
let CONV_BUSY = false;
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
// fichier d'index pour les créations dans la même seconde.
const ord = new Map(CONVS.map((c, i) => [c.id, i]));
CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|| ord.get(b.id) - ord.get(a.id));
CONV_ACTIVE = r.active || '';
CONV_BUSY = !!r.busy;
renderConversations();
}
function renderConversations(){
const cont = document.getElementById('conv-list');
if(!cont) 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();
convSyncBusy();
return;
}
for(const c of list){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
// qui travaille sans redessiner la liste (un redessin relancerait
// l'animation de l'anneau à chaque événement du flux).
row.dataset.conv = c.id;
row.onclick = () => convSwitch(c.id);
const info = document.createElement('div'); info.className = 'preset-info';
const nm = document.createElement('div'); nm.className = 'preset-name';
const title = c.title || 'Nouvelle discussion';
nm.appendChild(document.createTextNode(title));
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.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.append(info, when, acts);
cont.appendChild(row);
}
syncTopbarTitle();
convSyncBusy();
}
// --- Discussion au travail : anneau qui tourne ------------------------------
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané)
// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le
// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui
// n'est rechargée qu'en fin de tour.
function convBusyNow(){
const live = (typeof busy !== 'undefined') && busy;
if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live;
return CONV_BUSY || !!live;
}
// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son
// contenu : réécrire l'hôte remettrait l'animation à zéro.
function convSpinToggle(host, on, before){
if(!host) return;
let sp = host.querySelector(':scope > .conv-spin');
if(on && !sp){
sp = document.createElement('span');
sp.className = 'conv-spin';
sp.title = 'l\u2019agent travaille sur cette discussion\u2026';
sp.setAttribute('role', 'img');
sp.setAttribute('aria-label', 'génération en cours');
sp.appendChild(icon('spinner', 14));
if(before) host.insertBefore(sp, before); else host.appendChild(sp);
} else if(!on && sp){
sp.remove();
}
}
// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion
// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans
// ça, rien ne dirait que ça travaille).
function convSyncBusy(){
const on = convBusyNow();
const cont = document.getElementById('conv-list');
if(cont){
for(const row of cont.querySelectorAll('.preset')){
const mine = on && row.dataset.conv === CONV_ACTIVE;
row.classList.toggle('working', mine);
convSpinToggle(row, mine, row.firstChild);
}
}
// Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte
// à l'ellipse, un enfant y serait rogné avec lui sur un titre long.
const tb = document.getElementById('topbar-title');
convSpinToggle(tb && tb.parentNode, on, tb);
}
// 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
// discussions se lit d'un coup d'œil, pas au timestamp complet.
function convWhen(unix){
if(!unix) return '';
const d = new Date(unix * 1000), now = new Date();
const sameDay = d.toDateString() === now.toDateString();
return sameDay
? d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})
: d.toLocaleDateString([], {day:'2-digit', month:'2-digit'});
}
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'); }
}
async function convSwitch(id){
try{
const r = await jpost('/api/conversations/switch', {id});
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
async function convRename(id, current){
const t = await askPrompt('Nouveau titre de la discussion', {title:'Renommer', default:current, okText:'Renommer'});
if(!t) return;
try{
const r = await jpost('/api/conversations/rename', {id, title:t});
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
async function convDelete(id, title){
// 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});
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
// Identité — prénom et avatars affichés en tête des bulles du chat.
//
// Les valeurs vivent dans les préférences SERVEUR (comme le thème) : elles
// suivent donc d'un appareil à l'autre, au lieu de rester dans le localStorage
// d'un seul navigateur. La liste d'emojis est celle que sert le serveur — pas
// une seconde copie côté client, qui divergerait à la première modification.
let IDENT = {name:'', user:'🙂', ai:'🦊'};
let AVATARS = [];
// applyIdentity redessine les libellés des bulles DÉJÀ affichées : changer son
// prénom doit se voir tout de suite, sans recharger la page.
function applyIdentity(){
document.querySelectorAll('#chat .msg.user > .label, #chat .msg.assistant > .label')
.forEach(l => paintLabel(l, l.parentElement.classList.contains('user') ? 'user' : 'assistant'));
}
// paintLabel remplit un libellé : avatar + nom. Le prénom vient de
// l'utilisateur, donc textContent — jamais innerHTML.
function paintLabel(label, role){
if(!label) return;
label.textContent = '';
const av = document.createElement('i'); av.className = 'av';
av.textContent = role === 'user' ? (IDENT.user || '🙂') : (IDENT.ai || '🦊');
const who = document.createElement('span'); who.className = 'who';
who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki';
label.appendChild(av); label.appendChild(who);
// Badge du modèle : quelle IA a produit CETTE réponse. Le nom vit dans
// dataset.model (posé par tagModel, 09-stream.js) — c'est ce qui le fait
// survivre au repeint des libellés lors d'un changement d'identité.
if(role === 'assistant' && label.parentElement && label.parentElement.dataset.model){
label.appendChild(modelBadge(label.parentElement.dataset.model));
}
}
// modelBadge fabrique la pastille du nom de modèle (partagée paintLabel/tagModel).
function modelBadge(name){
const b = document.createElement('span');
b.className = 'modelbadge';
b.textContent = name;
b.title = 'modèle : ' + name;
return b;
}
async function loadIdentity(){
let r;
try{ r = await jget('/api/prefs'); }catch(_){ return; }
const p = r.prefs || {};
AVATARS = r.avatars || [];
IDENT = {
name: p.user_name || '',
user: p.avatar_user || '🙂',
ai: p.avatar_ai || '🦊',
};
const inp = document.getElementById('p-username');
if(inp && document.activeElement !== inp) inp.value = IDENT.name;
renderAvatarPicker('p-avatar-user', 'user');
renderAvatarPicker('p-avatar-ai', 'ai');
applyIdentity();
}
function renderAvatarPicker(elID, which){
const box = document.getElementById(elID);
if(!box) return;
box.innerHTML = '';
for(const e of AVATARS){
const b = document.createElement('button');
b.type = 'button'; b.textContent = e;
if(e === IDENT[which]) b.classList.add('sel');
b.onclick = () => pickAvatar(which, e);
box.appendChild(b);
}
}
async function pickAvatar(which, emoji){
IDENT[which] = emoji;
renderAvatarPicker(which === 'user' ? 'p-avatar-user' : 'p-avatar-ai', which);
applyIdentity();
try{ await jpost('/api/prefs', which === 'user' ? {avatar_user:emoji} : {avatar_ai:emoji}); }
catch(_){ toast('erreur réseau'); }
}
// Saisie du prénom : on n'écrit pas au serveur à chaque frappe.
let identTimer = null;
function saveIdentity(){
const inp = document.getElementById('p-username');
if(!inp) return;
IDENT.name = inp.value.trim();
applyIdentity();
clearTimeout(identTimer);
identTimer = setTimeout(async () => {
try{ await jpost('/api/prefs', {user_name: IDENT.name}); }catch(_){ toast('erreur réseau'); }
}, 500);
}
// ─── 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;
// La cible vit dans un panneau de la modale de réglages : ouvrir la modale
// sur ce panneau. C'est ce qui garde tous les appelants historiques
// (pastille d'état, jobs moteur) valides depuis la refonte en modale.
if(el.classList && el.classList.contains('set-pane') && typeof openSettings === 'function'){
openSettings(el.dataset.pane);
}
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 v = sel.value;
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
// active garde son contexte et son échantillonnage, seul MODEL change.
if(v && v.startsWith('m:')){
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
return;
}
const n = parseInt(v, 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);
}
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
// revient sur le modèle réellement chargé.
async function switchModelFile(value, name){
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
paintModelSwitch();
return;
}
toast('changement de modèle…');
let r = null;
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
toast('modèle en cours de chargement…');
loadAll();
}
// 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 = '', MS_FILES = [];
function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
paintModelSwitch();
}
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
async function loadModelSwitchFiles(){
try{
const r = await jget('/api/models');
MS_FILES = Array.isArray(r) ? r : [];
}catch(_){ return; }
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);
}
if(MS_PRESETS.length){
const g = document.createElement('optgroup'); g.label = 'Presets';
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;
g.appendChild(o);
});
sel.appendChild(g);
}
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
if(MS_FILES.length){
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
for(const m of MS_FILES){
const o = document.createElement('option');
o.value = 'm:' + m.value;
o.textContent = modelLabel(m.name);
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
g.appendChild(o);
}
sel.appendChild(g);
}
sel.disabled = !MS_PRESETS.length && !MS_FILES.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;
}
// ===== Loupe d'image =======================================================
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
// la conversation illisible. Le plafond ne se paie donc pas en information : un
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
//
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
// ne sait pas porter, d'où le blob: en premier lieu.
function lightboxEl(){
let el=document.getElementById('lightbox');
if(el) return el;
el=document.createElement('div');
el.id='lightbox';
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
+'<img alt=""><div class="lb-name"></div>';
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
// sinon on referme dès qu'on veut la regarder de près.
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
document.body.appendChild(el);
return el;
}
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
function _lbKey(e){
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
}
function openLightbox(src, name){
if(!src) return;
const el=lightboxEl(), img=el.querySelector('img');
img.src=src;
img.alt=name||'';
el.querySelector('.lb-name').textContent=name||'';
showModal(el);
document.addEventListener('keydown', _lbKey, true);
}
function closeLightbox(){
const el=document.getElementById('lightbox'); if(!el) return;
document.removeEventListener('keydown', _lbKey, true);
hideModal(el);
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
// laissait un cadre vide clignoter pendant la fermeture.
setTimeout(()=>{
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
}, 200);
}
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
//
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
// une image que le navigateur n'a pas le droit de charger seul.
function bindZoom(img, name){
if(!img || img.dataset.zoom) return;
img.dataset.zoom='1';
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
}
// 20-mode.js — le mode Chat/Code : sélecteur du composeur, puce de suggestion,
// panneau des critères d'acceptation, carte de question (outil ask).
// La bascule est PAR DISCUSSION et vit côté serveur (/api/chat/mode) : tous
// les appareils voient le même mode, comme le fil lui-même.
let CURRENT_MODE = 'chat';
function modeSeg(){ return document.getElementById('mode-seg'); }
function paintMode(){
const seg = modeSeg();
if(!seg) return;
seg.querySelector('[data-mode="chat"]').classList.toggle('on', CURRENT_MODE!=='code');
seg.querySelector('[data-mode="code"]').classList.toggle('on', CURRENT_MODE==='code');
// Le panneau des critères n'a de sens qu'en mode code.
const box = document.getElementById('criteria-box');
if(box && CURRENT_MODE!=='code') box.style.display='none';
}
// Bascule demandée depuis CE navigateur.
async function setMode(mode){
if(mode===CURRENT_MODE) return;
try{
const r = await jfetch('/api/chat/mode', {method:'POST', body: JSON.stringify({mode})});
const j = await r.json();
if(j.ok){ CURRENT_MODE = j.mode; paintMode(); hideCodeHint(); }
}catch(e){ toast('bascule impossible : '+e); }
}
// Bascule reçue du serveur (autre appareil, ou écho de la nôtre).
function applyModeDelta(mode){
CURRENT_MODE = mode==='code' ? 'code' : 'chat';
paintMode();
if(CURRENT_MODE==='code') hideCodeHint();
}
// Recharge le mode + critères au chargement et à chaque bascule de discussion.
async function modeOnConvChange(){
try{
const r = await jfetch('/api/chat/mode');
const j = await r.json();
if(!j.ok) return;
CURRENT_MODE = j.mode==='code' ? 'code' : 'chat';
paintMode();
renderCriteria(j.criteria||[]);
hideCodeHint();
}catch(e){}
}
// --- Puce « passer en mode Code ? » -----------------------------------------
// Émise par le serveur (delta code_hint) quand un message de mode chat
// ressemble à une tâche de code. Ignorable, jamais bloquante.
// Insère un bandeau DANS le composeur, au-dessus de la barre de saisie.
// #composer est en position:absolute : un élément posé AVANT lui dans le DOM
// reste dans le flux normal de la page et se retrouve donc CACHÉ dessous. Ses
// enfants, eux, suivent sa colonne (--col) et son empilement.
function composerBanner(id){
const composer = document.getElementById('composer');
if(!composer) return null;
let el = document.getElementById(id);
if(!el){
el = document.createElement('div');
el.id = id;
el.className = 'composer-banner';
composer.insertBefore(el, composer.firstChild);
}
return el;
}
function showCodeHint(){
if(CURRENT_MODE==='code' || document.getElementById('code-hint')) return;
const chip = composerBanner('code-hint');
if(!chip) return;
chip.innerHTML = 'ça ressemble à une tâche de code — <button type="button" id="code-hint-yes">passer en mode Code</button><button type="button" id="code-hint-no" title="ignorer" aria-label="ignorer">✕</button>';
chip.querySelector('#code-hint-yes').onclick = ()=>{ setMode('code'); };
chip.querySelector('#code-hint-no').onclick = hideCodeHint;
}
function hideCodeHint(){
const chip = document.getElementById('code-hint');
if(chip) chip.remove();
}
// --- Panneau des critères ----------------------------------------------------
// Rendu par les deltas {criteria:[...]} (outil criteria, passe de vérif, ou
// édition manuelle). Repliable ; en-tête = compteur passés / total.
function renderCriteria(list){
let box = document.getElementById('criteria-box');
if(!list || !list.length){ if(box) box.remove(); return; }
if(CURRENT_MODE!=='code'){ if(box) box.remove(); return; }
const fresh = !box;
box = composerBanner('criteria-box');
if(!box) return;
if(fresh){
box.innerHTML = '<div id="criteria-head"></div><ul id="criteria-list"></ul>';
box.querySelector('#criteria-head').onclick = ()=> box.classList.toggle('folded');
// Repliée par défaut : cinq critères dépliés mangeaient la moitié de
// l'écran au-dessus de la saisie. L'en-tête porte déjà le compteur.
box.classList.add('folded');
}
box.style.display='';
const passed = list.filter(c=>c.status==='passed').length;
box.querySelector('#criteria-head').textContent =
'critères ' + passed + '/' + list.length + (passed===list.length ? ' ✓' : '');
box.classList.toggle('allpass', passed===list.length);
const ul = box.querySelector('#criteria-list');
ul.innerHTML='';
for(const c of list){
const li = document.createElement('li');
li.className = 'crit-'+(c.status||'pending');
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
ul.appendChild(li);
}
}
function onVerifyDone(status){
if(status==='passed') toast('vérification : tous les critères passent ✓');
}
// --- Carte de question (outil ask) ------------------------------------------
// Le modèle a posé une question structurée et clos son tour : la carte propose
// ses options en boutons ; cliquer envoie la réponse comme un message normal.
function renderAskCard(ask){
if(!ask || !ask.question) return;
removeTyping();
const el = addMsg('assistant', '');
el.classList.add('askcard');
const body = el.querySelector('.body');
body.textContent = ask.question;
if(ask.options && ask.options.length){
const row = document.createElement('div');
row.className = 'askopts';
for(const opt of ask.options){
const b = document.createElement('button');
b.type = 'button';
b.textContent = opt;
b.onclick = ()=>{
// La réponse part comme un message utilisateur ordinaire.
const input = document.getElementById('input');
input.value = opt;
send();
row.querySelectorAll('button').forEach(x=>x.disabled=true);
};
row.appendChild(b);
}
body.appendChild(row);
}
scrollMaybe();
}
// Init : état du mode au chargement de la page.
window.addEventListener('DOMContentLoaded', modeOnConvChange);
// ─── Tâches planifiées ───────────────────────────────────────────────────────
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté.
//
// L'IA exécute une consigne toute seule sur une fréquence réglable (intervalle
// simple ou cron). Chaque tâche tourne ISOLÉE des discussions : elle livre son
// résultat via les propres outils de l'IA (mail via MCP, shell…), dans son
// dossier de travail à elle. L'interrupteur maître (« suspendre toutes les
// tâches ») permet de tout figer d'un coup pour discuter tranquille. Réutilise le
// gabarit visuel des serveurs MCP (mcp-row) et celui de l'éditeur de preset (pe-*).
let tasksList = [];
let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé)
let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune)
let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale)
let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche)
let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche)
let tasksPollTimer = null;
async function loadTasks(){ renderTasks(await jget('/api/tasks')); }
// Tant qu'une tâche tourne, on rafraîchit la liste toutes les 2 s (l'exécution est
// isolée du fil : sans sondage, on ne verrait ni son démarrage ni sa fin). On
// arrête le sondage dès qu'aucune tâche ne tourne — pas de poll permanent.
function ensureTasksPoll(){
if(TASK_RUNNING && !tasksPollTimer){
tasksPollTimer = setInterval(loadTasks, 2000);
} else if(!TASK_RUNNING && tasksPollTimer){
clearInterval(tasksPollTimer); tasksPollTimer = null;
}
}
function renderTasks(r){
tasksList = (r && r.tasks) || [];
const paused = !!(r && r.paused);
const agentOn = !!(r && r.agent);
TASK_PRESETS = (r && r.presets) || [];
TASK_MEM_ON = !r || r.mem_on !== false;
TASK_WEB_ON = !r || r.web_on !== false;
TASK_RUNNING = (r && r.running_id) || '';
ensureTasksPoll();
const pc = document.getElementById('tasks-pause-toggle');
if(pc) pc.checked = paused;
// Avertissement : sans mode agent, une tâche n'a aucun outil pour agir.
const warn = document.getElementById('tasks-agent-warn');
if(warn) warn.style.display = (tasksList.length && !agentOn) ? '' : 'none';
// Pastille : « en cours » l'emporte, sinon le nombre de tâches actives.
const nActive = tasksList.filter(t=>t.enabled).length;
if(TASK_RUNNING) setBadge('tasks-badge', 'warn', 'en cours');
else setBadge('tasks-badge', nActive ? true : null, nActive ? nActive+' active'+(nActive>1?'s':'') : '');
const list = document.getElementById('tasks-list');
list.textContent = '';
if(!tasksList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucune tâche)</div>';
return;
}
tasksList.forEach(t=>{
const running = (t.id === TASK_RUNNING);
const row = document.createElement('div');
row.className = 'mcp-row'+(t.enabled?'':' off')+(running?' task-running':'');
const dot = document.createElement('span');
dot.className = 'mcp-dot';
if(running){
// Point animé pendant l'exécution.
dot.classList.add('mcp-dot-ok','task-dot-run');
} else if(!t.enabled){ dot.classList.add('mcp-dot-off'); }
else if(t.last_run && !t.last_ok){ dot.classList.add('mcp-dot-err'); }
else if(t.last_run && t.last_ok){ dot.classList.add('mcp-dot-ok'); }
else { dot.classList.add('mcp-dot-off'); }
dot.title = running ? 'en cours d’exécution'
: (!t.enabled ? 'désactivée'
: (t.last_run ? (t.last_ok ? 'dernier passage : ok' : 'dernier passage : échec') : 'jamais exécutée'));
const info = document.createElement('div');
info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name';
nm.textContent = t.name; nm.title = t.name;
const meta = document.createElement('div'); meta.className = 'mcp-meta';
if(running){
const rn = document.createElement('span'); rn.className = 'task-run-badge';
rn.textContent = 'en cours…'; meta.appendChild(rn);
} else {
const fr = document.createElement('span'); fr.className = 'mcp-tag';
fr.textContent = scheduleLabel(t.schedule); meta.appendChild(fr);
if(t.preset){
const pr = document.createElement('span'); pr.className = 'mcp-tag mcp-tag-preset';
const pn = (TASK_PRESETS.find(p=>p.id===t.preset)||{}).name || t.preset;
pr.textContent = pn; pr.title = 'preset : '+pn; meta.appendChild(pr);
}
if(t.enabled && t.next_run){
const nx = document.createElement('span'); nx.className = 'mcp-tools';
nx.textContent = '→ '+taskWhen(t.next_run);
nx.title = 'prochaine exécution : '+taskWhen(t.next_run);
meta.appendChild(nx);
}
if(t.last_run && !t.last_ok && t.last_error){
const er = document.createElement('span'); er.className = 'mcp-err';
er.textContent = 'échec'; er.title = t.last_error;
meta.appendChild(er);
}
}
info.appendChild(nm); info.appendChild(meta);
row.appendChild(dot); row.appendChild(info);
if(running){
// Bouton arrêter : passe par /api/chat/stop (conv.Stop annule le contexte de
// la tâche, cf. RunAutonomous).
const st = document.createElement('button');
st.className = 'task-stop-btn'; st.textContent = 'arrêter';
st.onclick = (e)=>{ e.stopPropagation(); stopRunningTask(); };
row.appendChild(st);
} else {
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
sw.onclick = (e)=>e.stopPropagation();
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!t.enabled;
cb.onchange = ()=>toggleTask(t.id, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const edit = document.createElement('button');
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
edit.onclick = (e)=>{ e.stopPropagation(); openTask(t.id); };
row.appendChild(sw); row.appendChild(edit);
}
row.onclick = ()=> running ? null : openTask(t.id);
list.appendChild(row);
});
}
async function stopRunningTask(){
await jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{});
toast('arrêt de la tâche…');
setTimeout(loadTasks, 800);
}
// taskWhen met en forme un epoch ms dans le FUSEAU du navigateur (jj/mm HH:MM) —
// le serveur envoie l'horodatage brut, sinon il serait figé en UTC (décalage).
// Nom distinct du fmtWhen du panneau Fichiers, qui prend des SECONDES et rend
// autre chose : deux vues, deux formats, aucune raison de les confondre.
function taskWhen(ms){
if(!ms) return '';
const d = new Date(ms);
const p = n => String(n).padStart(2,'0');
return p(d.getDate())+'/'+p(d.getMonth()+1)+' '+p(d.getHours())+':'+p(d.getMinutes());
}
// (fmtDur vit dans 09-stream.js : même unité, même rendu — on ne le redouble pas.)
// scheduleLabel rend une fréquence lisible pour la pastille de la liste.
function scheduleLabel(s){
s = (s||'').trim();
const m = /^@every\s+(.+)$/i.exec(s);
if(m){
const d = m[1].trim();
const mm = /^(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?$/.exec(d);
if(mm){
let n = parseInt(mm[1], 10), u = mm[2]; const tm = mm[3];
// Anciennes tâches « jours » stockées en heures (multiple de 24).
if(u === 'h' && n % 24 === 0 && n >= 24){ n = n/24; u = 'd'; }
const ul = {m:'min', h:'h', d:'jour'}[u] || u;
let lbl = 'toutes les '+n+' '+ul+((u==='d'&&n>1)?'s':'');
if(u==='d' && tm) lbl += ' à '+tm;
return lbl;
}
return 'toutes les '+d;
}
return 'cron : '+s;
}
async function toggleTask(id, on){
await jpost('/api/tasks/toggle', {id, on});
await loadTasks();
toast(on ? 'tâche activée' : 'tâche désactivée');
}
async function toggleTasksPause(){
const on = document.getElementById('tasks-pause-toggle').checked;
await jpost('/api/tasks/pause', {on});
toast(on ? 'tâches suspendues' : 'tâches réactivées');
loadTasks();
}
// Ouvre la modale d'ajout (id='') ou d'édition (id existant).
function openTask(id){
taskEditing = id || '';
const t = id ? tasksList.find(x=>x.id===id) : null;
document.getElementById('task-modal-title').textContent = t ? 'Éditer « '+t.name+' »' : 'Nouvelle tâche';
document.getElementById('task-name').value = t ? t.name : '';
document.getElementById('task-prompt').value = t ? t.prompt : '';
document.getElementById('task-enabled').checked = t ? !!t.enabled : true;
// Accès mémoire/web : sur une tâche existante on lit son réglage (no_mem/no_web,
// stockés en négation) ; sur une nouvelle on suit l'état global de la machine.
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
fillPresetSelect(t ? (t.preset||'') : '');
// Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle :
// « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours).
const sched = t ? (t.schedule||'') : '@every 2h';
const m = /^@every\s+(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?\s*$/i.exec(sched);
document.getElementById('task-time').value = '09:00';
if(m || !t){
setTaskFreqMode('interval');
document.getElementById('task-interval-n').value = m ? m[1] : '2';
document.getElementById('task-interval-unit').value = m ? m[2] : 'h';
if(m && m[3]){ const tm = m[3]; document.getElementById('task-time').value = tm.length===4 ? '0'+tm : tm; }
document.getElementById('task-cron').value = '';
} else {
setTaskFreqMode('cron');
document.getElementById('task-cron').value = sched;
}
taskUnitUI();
document.getElementById('task-modal-status').textContent = '';
renderTaskResult(t);
document.getElementById('task-del').style.display = t ? '' : 'none';
document.getElementById('task-run').style.display = t ? '' : 'none';
showModal('task-modal');
}
// renderTaskResult remplit le panneau « Dernier résultat » avec le compte-rendu
// COMPLET (défilable), l'état (ok / échec / en cours) et l'horodatage. Masqué pour
// une tâche neuve ou jamais exécutée.
function renderTaskResult(t){
const block = document.getElementById('task-result-block');
const box = document.getElementById('task-result');
const when = document.getElementById('task-result-when');
box.className = 'task-report';
if(!t || (!t.last_run && t.id !== TASK_RUNNING)){ block.style.display = 'none'; return; }
block.style.display = '';
if(t.id === TASK_RUNNING){
when.textContent = '(en cours)';
box.classList.add('report-run');
box.textContent = 'exécution en cours…';
return;
}
const parts = [];
if(t.last_run) parts.push(new Date(t.last_run).toLocaleString());
if(t.last_dur_ms) parts.push('en '+fmtDur(t.last_dur_ms));
when.textContent = parts.length ? '· '+parts.join(' · ') : '';
if(!t.last_ok){
box.classList.add('report-err');
box.textContent = 'Échec : '+(t.last_error||'erreur inconnue');
return;
}
const rep = (t.last_report||'').trim();
if(rep){ box.classList.add('md-report'); box.innerHTML = md(rep); }
else { box.classList.add('report-empty'); box.textContent = '(terminée sans texte de compte-rendu — l\'action a été faite via les outils)'; }
}
function closeTask(){ hideModal('task-modal'); taskEditing = null; }
// fillPresetSelect peuple le sélecteur de preset. Première option = « preset
// actif » (vide) : la tâche utilise le modèle en cours au moment de l'exécution.
function fillPresetSelect(selected){
const sel = document.getElementById('task-preset');
sel.textContent = '';
const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = 'preset actif';
sel.appendChild(opt0);
TASK_PRESETS.forEach(p=>{
const o = document.createElement('option'); o.value = p.id;
o.textContent = p.name + (p.active ? ' (actif)' : '');
sel.appendChild(o);
});
sel.value = selected || '';
}
function setTaskFreqMode(mode){
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
if(r) r.checked = true;
taskFreqUI(mode);
}
function taskFreqUI(mode){
document.getElementById('task-interval-fields').style.display = mode==='interval' ? '' : 'none';
document.getElementById('task-cron-fields').style.display = mode==='cron' ? '' : 'none';
taskUnitUI();
}
// taskUnitUI : la ligne « À (heure) » n'apparaît que pour un intervalle en JOURS
// (« tous les N jours à HH:MM »). Sans intérêt pour des minutes/heures.
function taskUnitUI(){
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value;
const unit = document.getElementById('task-interval-unit').value;
document.getElementById('task-time-row').style.display = (mode==='interval' && unit==='d') ? '' : 'none';
}
// buildSchedule sérialise l'état du formulaire de fréquence en chaîne serveur.
function buildSchedule(){
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value || 'interval';
if(mode==='cron') return document.getElementById('task-cron').value.trim();
const n = parseInt(document.getElementById('task-interval-n').value, 10);
const u = document.getElementById('task-interval-unit').value;
if(!n || n < 1) return '';
// Jours : on ancre à une heure de la journée (« @every Nd@HH:MM »).
if(u === 'd'){
const tm = document.getElementById('task-time').value || '09:00';
return '@every '+n+'d@'+tm;
}
return '@every '+n+u;
}
async function saveTask(){
const name = document.getElementById('task-name').value.trim();
const prompt = document.getElementById('task-prompt').value.trim();
const schedule = buildSchedule();
const enabled = document.getElementById('task-enabled').checked;
const preset = document.getElementById('task-preset').value;
const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
const no_mem = !document.getElementById('task-mem').checked;
const no_web = !document.getElementById('task-web').checked;
const st = document.getElementById('task-modal-status');
if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; }
if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; }
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web});
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
closeTask();
await loadTasks();
toast('tâche enregistrée');
}
async function deleteTaskUI(){
if(!taskEditing) return;
const t = tasksList.find(x=>x.id===taskEditing);
if(!await askConfirm('Supprimer la tâche « '+(t?t.name:'')+' » ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return;
await jpost('/api/tasks/delete', {id: taskEditing});
closeTask();
await loadTasks();
toast('tâche supprimée');
}
async function runTaskNow(){
if(!taskEditing) return;
const st = document.getElementById('task-modal-status');
const r = await jpost('/api/tasks/run', {id: taskEditing});
if(r && r.ok){
st.textContent = 'lancée, suis son avancement dans la liste';
st.style.color = '';
closeTask();
// Rafraîchit tout de suite : loadTasks détecte running_id et lance le sondage
// qui suivra l'exécution jusqu'à la fin.
setTimeout(loadTasks, 400);
} else {
st.textContent = (r && r.error) || 'impossible de lancer maintenant';
st.style.color = 'var(--err)';
}
}
// ─── Réglages en modale ──────────────────────────────────────────────────────
// Toutes les sections de réglages vivent dans une modale à deux volets : la nav
// des sections à gauche, LE panneau choisi à droite. La barre latérale ne garde
// que les discussions et le moniteur machine — visuellement plus simple, et
// chaque section dispose enfin de toute la largeur.
//
// Les ids historiques des sections (svc-log-box, lc-details, tasks-details…)
// sont posés sur les .set-pane : openDetails() (18-shell.js) les résout vers le
// bon panneau, si bien que tous les chemins existants — pastille d'état du
// moteur, pastille d'installation, jobs llama.cpp — rouvrent la modale au bon
// endroit sans changer une ligne de leurs appelants.
let SET_LAST = 'config';
// Panneaux à contenu paresseux : chargés à l'affichage — l'équivalent de
// l'ancien ontoggle des <details>.
const SET_HOOKS = {
'engine-log': () => { loadSvcLog(); showPaths(); },
'dictee': () => { loadDictateSettings(); },
};
function openSettings(pane){
showModal('settings-modal');
settingsShow(pane || SET_LAST);
}
function closeSettings(){ hideModal('settings-modal'); }
function settingsShow(pane){
SET_LAST = pane;
document.querySelectorAll('#set-nav [data-pane]').forEach(b => b.classList.toggle('active', b.dataset.pane === pane));
document.querySelectorAll('#set-body .set-pane').forEach(s => s.classList.toggle('active', s.dataset.pane === pane));
const body = document.getElementById('set-body');
if(body) body.scrollTop = 0;
const h = SET_HOOKS[pane]; if(h) h();
}
// Échap ferme la modale — capture:false, donc les modales à confirmation
// (ask-modal, qui écoute en capture) gardent la main quand elles sont ouvertes.
document.addEventListener('keydown', e => {
if(e.key !== 'Escape') return;
const m = document.getElementById('settings-modal');
if(m && m.classList.contains('show')) closeSettings();
});
// ─── Dictée vocale ───────────────────────────────────────────────────────────
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
//
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
// l'explique plutôt que d'échouer en silence.
let MIC = null; // {ctx, stream, src, node, chunks, rate, timer}
async function dictateToggle(){
if(MIC){ dictateStop(); return; }
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
toast('micro bloqué par le navigateur : il faut HTTPS (ou localhost)');
return;
}
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
}catch(e){ toast('accès au micro refusé'); return; }
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
// ScriptProcessor : déprécié mais universel, et amplement suffisant pour
// copier des blocs de PCM (AudioWorklet demanderait un module séparé,
// impossible dans un fichier unique servi inline).
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
src.connect(node); node.connect(ctx.destination);
// Garde-fou : une dictée oubliée ne tourne pas des heures.
MIC = {ctx, stream, src, node, chunks, rate:ctx.sampleRate, timer:setTimeout(dictateStop, 90000)};
document.getElementById('mic').classList.add('rec');
}
async function dictateStop(){
const m = MIC; if(!m) return; MIC = null;
clearTimeout(m.timer);
try{ m.src.disconnect(); m.node.disconnect(); }catch(_){}
m.stream.getTracks().forEach(t => t.stop());
try{ m.ctx.close(); }catch(_){}
const btn = document.getElementById('mic');
btn.classList.remove('rec'); btn.classList.add('busy'); btn.disabled = true;
try{
const wav = encodeWav16k(m.chunks, m.rate);
if(wav.byteLength < 16000){ toast('rien entendu'); return; } // < 0,5 s
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body:wav});
const j = await r.json().catch(()=>({}));
if(r.status === 503 && j.downloading){
// Le serveur relance le téléchargement à chaque tentative ; s'il a déjà
// échoué (conteneur sans accès réseau, Hugging Face injoignable), il nous
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — suivi dans Paramètres → Dictée');
return;
}
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
const text = (j.text||'').trim();
if(!text){ toast('rien compris — réessaie'); return; }
const ta = document.getElementById('input');
ta.value = ta.value.trim() ? ta.value.replace(/\s+$/,'') + ' ' + text : text;
autoGrow(ta); ta.focus();
}catch(_){ toast('erreur réseau'); }
finally{ btn.classList.remove('busy'); btn.disabled = false; }
}
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
function encodeWav16k(chunks, fromRate){
const toRate = 16000;
let n = 0; for(const c of chunks) n += c.length;
const all = new Float32Array(n); let o = 0;
for(const c of chunks){ all.set(c, o); o += c.length; }
const ratio = fromRate / toRate, outN = Math.floor(n / ratio);
const pcm = new Int16Array(outN);
for(let i = 0; i < outN; i++){
const s = Math.max(-1, Math.min(1, all[Math.floor(i*ratio)] || 0));
pcm[i] = s < 0 ? s*0x8000 : s*0x7fff;
}
const buf = new ArrayBuffer(44 + pcm.length*2), v = new DataView(buf);
const wr = (off, s) => { for(let i = 0; i < s.length; i++) v.setUint8(off+i, s.charCodeAt(i)); };
wr(0,'RIFF'); v.setUint32(4, 36 + pcm.length*2, true); wr(8,'WAVEfmt ');
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, toRate, true); v.setUint32(28, toRate*2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
wr(36,'data'); v.setUint32(40, pcm.length*2, true);
new Int16Array(buf, 44).set(pcm);
return buf;
}
// ─── Réglages de la dictée ───────────────────────────────────────────────────
// Panneau Paramètres → Dictée : matériel, modèle, langue, réactivité, plus un
// test du micro. Ce sont des réglages de SERVEUR (le modèle chargé et le GPU
// occupé appartiennent à la machine), donc rien n'est gardé en localStorage :
// tout passe par /api/dictate/*.
let DIC_CFG = null;
async function loadDictateSettings(){
const [cfg, modeles, gpus] = await Promise.all([
jfetch('/api/dictate/config').then(r => r.json()).catch(()=>({})),
jfetch('/api/dictate/models').then(r => r.json()).catch(()=>[]),
jfetch('/api/vram').then(r => r.json()).catch(()=>[]),
]);
DIC_CFG = cfg || {};
// Matériel : « CPU seul » d'abord — c'est le choix qui marche partout, y
// compris sur une image bâtie sans CUDA.
const dev = document.getElementById('dic-device');
dev.innerHTML = '';
dev.appendChild(new Option('processeur seul', 'cpu'));
(gpus||[]).forEach((g, i) => {
const libre = Math.max(0, (g.total|0) - (g.used|0));
dev.appendChild(new Option(`GPU ${i} — ${g.name} (${libre} Mo libres sur ${g.total|0})`, String(i)));
});
dev.value = cfg.device || 'cpu';
const mod = document.getElementById('dic-model');
mod.innerHTML = '';
(modeles||[]).forEach(m => {
const mo = Math.round(m.octets / 1e6);
// Dire ce qui est déjà là : sans ça, choisir un modèle absent ressemble à
// un réglage instantané alors que c'est un téléchargement de 600 Mo.
const etat = m.present ? 'déjà téléchargé' : mo + ' Mo à télécharger';
mod.appendChild(new Option(`${m.nom} — ${etat}, ~${m.vram_mo} Mo de mémoire`, m.id));
});
mod.value = cfg.model || '';
document.getElementById('dic-lang').value = cfg.lang || 'fr';
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
refreshDictateState();
}
async function dictateSave(){
const cfg = {
model: document.getElementById('dic-model').value,
lang: document.getElementById('dic-lang').value,
device: document.getElementById('dic-device').value,
reactivity: document.getElementById('dic-react').value,
};
const el = document.getElementById('dic-save-state');
el.textContent = 'enregistrement…';
const r = await jfetch('/api/dictate/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfg)});
const j = await r.json().catch(()=>({}));
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
DIC_CFG = cfg;
// Le serveur de dictée a été arrêté côté Loki : le prochain clic sur le
// micro le relancera avec ces réglages. On le dit, sinon la première dictée
// suivante paraît anormalement lente sans raison visible.
el.textContent = 'enregistré — la prochaine dictée rechargera le modèle';
refreshDictateState();
}
async function dictateDownload(){
const id = document.getElementById('dic-model').value;
const el = document.getElementById('dic-dl-state');
el.textContent = 'démarrage…';
const r = await jfetch('/api/dictate/models/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})});
const j = await r.json().catch(()=>({}));
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
suivreTelechargement();
}
// Sondage tant que le téléchargement tourne. Un modèle de 1 Go prend plusieurs
// minutes : sans progression affichée, l'utilisateur ne sait pas s'il se passe
// quelque chose.
async function suivreTelechargement(){
const el = document.getElementById('dic-dl-state');
const r = await jfetch('/api/dictate/models/download');
const j = await r.json().catch(()=>({}));
if(j.error){ el.textContent = 'échec : ' + j.error; return; }
if(j.running){
el.textContent = `téléchargement de ${j.id} — ${j.pct||0} %`;
setTimeout(suivreTelechargement, 1000);
return;
}
el.textContent = 'téléchargé';
loadDictateSettings();
}
async function refreshDictateState(){
const el = document.getElementById('dic-state');
if(!el) return;
const j = await jfetch('/api/dictate/state').then(r => r.json()).catch(()=>null);
if(!j){ el.textContent = ''; return; }
const bouts = [];
bouts.push(j.actif ? `moteur allumé depuis ${j.depuis_s||0} s` : 'moteur éteint (il démarre au premier clic sur le micro)');
bouts.push(j.present ? `modèle ${j.modele} présent` : `modèle ${j.modele} ABSENT — à télécharger`);
// note : le serveur nous dit quand il n'a pas pu respecter le matériel
// demandé (image sans CUDA, par exemple). Le taire ferait croire que le GPU
// choisi est utilisé.
if(j.note) bouts.push('⚠ ' + j.note);
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
el.textContent = bouts.join(' · ');
}
// Test du micro : capte 3 s, mesure le niveau réel et transcrit, SANS toucher
// au champ de saisie. C'est le diagnostic qu'il fallait bricoler à la main
// quand la dictée ne rendait rien — niveau nul, micro muet, serveur en panne
// se ressemblent tous de l'extérieur.
async function dictateTestMic(){
const out = document.getElementById('dic-test-out');
const btn = document.getElementById('dic-test');
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
out.textContent = 'micro bloqué par le navigateur : il faut HTTPS (ou localhost)';
return;
}
btn.disabled = true;
out.textContent = 'écoute… parle maintenant (3 s)';
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
}catch(e){ out.textContent = 'accès au micro refusé'; btn.disabled = false; return; }
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = e => chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
src.connect(node); node.connect(ctx.destination);
await new Promise(r => setTimeout(r, 3000));
try{ src.disconnect(); node.disconnect(); }catch(_){}
stream.getTracks().forEach(t => t.stop());
const rate = ctx.sampleRate;
try{ ctx.close(); }catch(_){}
let n = 0; for(const c of chunks) n += c.length;
let somme = 0, crete = 0;
for(const c of chunks) for(let i = 0; i < c.length; i++){
const v = c[i]; somme += v*v;
const a = Math.abs(v); if(a > crete) crete = a;
}
const rms = Math.sqrt(somme / (n||1));
const niveau = rms < 0.002 ? 'QUASI MUET' : rms < 0.01 ? 'faible' : 'correct';
out.textContent = `niveau ${niveau} (rms ${rms.toFixed(4)}, crête ${crete.toFixed(3)}) — transcription…`;
const wav = encodeWav16k(chunks, rate);
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body: wav});
const j = await r.json().catch(()=>({}));
const tete = `niveau ${niveau} (rms ${rms.toFixed(4)}) — `;
if(!r.ok) out.textContent = tete + (j.error || 'échec de la transcription');
else if(!(j.text||'').trim()) out.textContent = tete + 'rien compris' + (rms < 0.002 ? ' (le micro n’a rien capté : vérifie l’entrée choisie par le système)' : '');
else out.textContent = tete + '« ' + j.text.trim() + ' »';
btn.disabled = false;
refreshDictateState();
}
</script></body></html>