mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Le renommage des dossiers avait ete repercute dans release.yml mais pas dans ci.yml, qui compilait encore ./cmd/jean : les deux jobs echouaient. Une occurrence trainait aussi dans mac-build.yml (cmd/jean/icon.ico), que le remplacement precedent n'avait attrapee que pour icon.png. Un cas n'etait pas qu'un commentaire : tools/assemble-ui utilisait "internal/jean/ui" comme chemin par defaut. La directive go:generate passe le dossier en argument, donc la generation fonctionnait, mais l'invocation directe documentee dans son propre en-tete (go run ./tools/assemble-ui) aurait echoue. Le reste etait des commentaires designant les anciens chemins, corriges pour que le depot ne mente plus sur sa propre structure. Job ci rejoue en entier en local : gofmt, vet, build, tests et les quatre cross-compilations de release.
3634 lines
220 KiB
HTML
3634 lines
220 KiB
HTML
<!doctype html>
|
||
<!-- GÉNÉRÉ depuis ui/src/ par tools/assemble-ui (go generate ./internal/ajean) — 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="AJEAN">
|
||
<title>AJEAN</title>
|
||
<!-- Favicon (même marque « ajean » que la landing/portail) + icône PWA (écran d'accueil
|
||
iOS). SVG inline pour le favicon, PNG 180×180 en data-URI pour apple-touch-icon
|
||
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%23000'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
|
||
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABBUlEQVR42u3SAQkAAAwCQfuX3lI4GNxF0E8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIArU2NbcYhDHOIQhzgQB+JAHIgDcSAOxIE4xCEOcYhDHIgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQByIQxziEIc4xIE4EAfiQByIA3EgDsQhDnGIQxziEIc4EAfiQByIAxciDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAciEMc4hCHOMQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADfLBHMhi4TnG6EAAAAAElFTkSuQmCC">
|
||
<!-- 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('jean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
|
||
<style>
|
||
/* ===== Palette =====================================================
|
||
Un seul thème (« Clean ») : noir et blanc, sobre. 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. */
|
||
:root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)}
|
||
[data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)}
|
||
*{box-sizing:border-box}
|
||
/* ⚠️ 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:6px;font-size:18px;cursor:pointer}
|
||
#backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10}
|
||
@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}
|
||
body.drawer-open #menubtn{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}
|
||
#send,#stop{padding:0 12px}
|
||
/* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et
|
||
ça prend de la place. Elle reste visible sur desktop. */
|
||
#sendhint{display:none}
|
||
}
|
||
/* 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:9px}
|
||
.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 : « AJEAN » 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}
|
||
#brand svg{height:10px;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:18px}
|
||
/* 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}
|
||
.statuspill .port{opacity:.6;font-weight:400}
|
||
/* 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}
|
||
.addbtn{font-size:11px;padding:3px 11px 4px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;line-height:1.2}
|
||
.addbtn:hover{border-color:var(--accent);color:var(--accent)}
|
||
.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 englobante .gated-block. */
|
||
/* Barre repliable « Pages » : un seul chevron, bouton + ajouter aligné à droite. */
|
||
.pages-bar{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;padding:7px 0;margin-top:4px}
|
||
.pages-bar .pages-caret{flex-shrink:0;font-size:9px;color:var(--dim);transition:transform .12s}
|
||
.pages-bar.open .pages-caret{transform:rotate(90deg)}
|
||
.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:6px;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-list .preset{margin:3px 0;padding:5px 9px}
|
||
.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:10px;width:100%;max-width:420px;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.18);animation:askpop .12s ease-out}
|
||
@keyframes askpop{from{opacity:0;transform:translateY(6px) scale(.98)}to{opacity:1;transform:none}}
|
||
.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:6px;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 « JEAN »). L'état agent reste signalé par sa
|
||
pastille dans les réglages. */
|
||
#brand.agent #brand-a{color:var(--accent)}
|
||
/* interrupteur (switch) */
|
||
/* Sous-réglage du mode agent inaccessible quand l'agent est off : grisé + inerte. */
|
||
/* Sous-réglages du mode agent présentés en CARTES (bordure + fond) pour ne pas
|
||
les confondre avec les traits des sections principales. */
|
||
/* Bloc du mode agent = carte de liste : le rembourrage est porté par les
|
||
lignes (comme .slist), pas par la carte, sinon les retraits se cumulent. */
|
||
/* ⚠️ PAS d'overflow:hidden ici : les bulles d'aide (« ? ») sont positionnées en
|
||
absolu par rapport à la ligne, donc un débordement caché les TRONQUE — c'est
|
||
ce qui rendait plusieurs infobulles illisibles. */
|
||
.gated-block{margin:6px 0;border:1px solid var(--border);border-radius:10px;padding:0 0 10px;background:var(--panel);overflow:visible;transition:opacity .15s}
|
||
.gated-block>.switchrow:first-of-type{padding-top:0}
|
||
.gated-block.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:6px;cursor:pointer}
|
||
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
|
||
details[open]>summary>.preset-add{display:inline-block}
|
||
.tag{display:inline-block;padding:2px 8px;border-radius:10px;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:6px;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:6px;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}
|
||
.preset-info{flex:1;min-width:0}
|
||
.preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word}
|
||
.preset-edit{flex-shrink:0;align-self:flex-start;margin:-3px -4px 0 0;padding:2px 4px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.6}
|
||
.preset-edit:hover{opacity:1;color:var(--accent)}
|
||
.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:8px;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:8px;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:8px;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:6px;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:10px;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:6px;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:5px;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:6px;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){background:var(--accent)}
|
||
.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}
|
||
.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}
|
||
.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}
|
||
/* Premier élément d'une carte : sa marge haute s'ajoutait au rembourrage de la
|
||
carte (18 px de vide au-dessus de « Mémoire », « Accès internet », etc.). */
|
||
.gated-block>:first-child{margin-top:0}
|
||
/* 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 : cf. la note sur .gated-block — les infobulles doivent
|
||
pouvoir déborder de la carte. Les lignes n'ont pas de fond propre, l'arrondi
|
||
n'a donc rien à rogner. */
|
||
.slist,.gated-block{background:var(--panel);border:1px solid var(--border);border-radius:9px;
|
||
overflow:visible;margin:0 0 8px}
|
||
.slist:last-child,.gated-block:last-child{margin-bottom:0}
|
||
/* Contenu libre (champ + boutons) : le rembourrage passe sur la carte. */
|
||
.slist.pad,.gated-block{padding:8px 10px}
|
||
.slist.pad>:first-child{margin-top:0}
|
||
.slist.pad>:last-child{margin-bottom:0}
|
||
|
||
/* --- 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:7px;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){font-size:12px;padding:6px 10px;border-radius:7px}
|
||
.side .row{display:flex;gap:6px;align-items:center;margin:6px 0}
|
||
.side .row .sctl,.side .row .io-input{flex:1;min-width:0}
|
||
.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épliables au clic)
|
||
data-hide-side : barre latérale en tiroir, même sur ordinateur
|
||
Quand raisonnement et outils sont masqués, l'activité reste signalée par
|
||
l'indicateur de frappe (.msg.typing) maintenu pendant tout le tour. */
|
||
[data-hide-reasoning="1"] #chat .msg.reasoning{display:none}
|
||
[data-hide-tools="1"] #chat .msg.tool{display:none}
|
||
/* Barre latérale en tiroir sur TOUTES les tailles d'écran : mêmes règles que
|
||
la version mobile, activées par l'attribut au lieu de la largeur. */
|
||
[data-hide-side="1"] .side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left));border-right:1px solid var(--border)}
|
||
@media (min-width:721px){[data-hide-side="1"] .side{width:380px;max-width:380px}}
|
||
/* Le bouton ☰ est posé au-dessus du fil : on dégage le haut de la
|
||
conversation pour qu'il ne recouvre aucun message. */
|
||
@media (min-width:721px){html[data-hide-side="1"] #chat{padding-top:60px}}
|
||
[data-hide-side="1"] .side.open{transform:translateX(0)}
|
||
[data-hide-side="1"] #menubtn{display:block}
|
||
[data-hide-side="1"] #backdrop.open{display:block}
|
||
[data-hide-side="1"] body.drawer-open #menubtn{display:none}
|
||
[data-hide-side="1"] .main{width:100%}
|
||
/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui
|
||
peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli.
|
||
width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0
|
||
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}
|
||
.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-text{font-size:10px;letter-spacing:.03em;white-space:nowrap}
|
||
#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}
|
||
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
|
||
#input:focus{outline:none;border-color:var(--accent)}
|
||
#send{padding:0 20px}
|
||
.muted{color:var(--dim);font-size:13px}
|
||
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||
#toast.show{opacity:1}
|
||
@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:8px;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}
|
||
#send,#stop{padding:0 14px}
|
||
#composer .muted{display:none}
|
||
#ctxmeta{padding:3px 10px 0}
|
||
#ctx-text{font-size:9px}
|
||
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))}
|
||
}
|
||
/* --- Backend llama.cpp (2 modes : rapide / optimisée) --------------------- */
|
||
.lc-head{font-size:12px;line-height:1.5;margin-bottom:10px}
|
||
.lc-modes{display:flex;flex-direction:column;gap:8px}
|
||
.lc-modes.busy{opacity:.5;pointer-events:none}
|
||
.lc-mode{border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .12s,background .12s}
|
||
.lc-mode:hover{border-color:var(--dim)}
|
||
.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:8px;padding:1px 6px}
|
||
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
|
||
.lc-mode-state{font-size:11px;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}
|
||
.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:12px;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}
|
||
.pe-row:not(:last-child){border-bottom: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}
|
||
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;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 8px 8px 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:9px;padding:3px;width:100%}
|
||
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:7px;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:12px;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:13px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
|
||
.pe-foot .foot-r{display:flex;gap:8px}
|
||
.pe-btn{margin:0;padding:9px 18px;border-radius:9px;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--panel);color:var(--text)}
|
||
.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)}
|
||
.pe-btn.ghost-danger{border-color:transparent;background:transparent;color:var(--err);padding:9px 10px}
|
||
.pe-btn.ghost-danger:hover{background:var(--danger-bg)}
|
||
.pe-delmodel{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dim);cursor:pointer}
|
||
.pe-delmodel input{margin:0}
|
||
/* É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}
|
||
.modal-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;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}
|
||
.modal-x{margin:0;padding:3px 10px;font-size:16px;line-height:1;color:var(--dim)}
|
||
.modal-body{padding:16px;overflow:auto;flex:1}
|
||
.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:7px;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:10px;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:8px;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:4px}
|
||
.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:6px;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:6px;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:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||
.mcp-tools{font-size:10px;color:var(--accent)}
|
||
.mcp-err{font-size:10px;color:var(--err)}
|
||
.mcp-switch{flex:none}
|
||
.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5}
|
||
.mcp-edit:hover{opacity:1;color:var(--accent)}
|
||
/* Liste des outils MCP dans la modale */
|
||
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px}
|
||
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer}
|
||
.mcp-tool-row:hover{background:var(--panel)}
|
||
.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:9px;padding:8px 10px;font:inherit;font-size:13px}
|
||
|
||
/* Toggles de la sous-section « Paramètres » du mode agent : chacun dans sa
|
||
propre carte bordée et arrondie (comme les serveurs MCP), plus lisible. */
|
||
#param-block .switchrow{border:1px solid var(--border);border-radius:8px;padding:9px 11px;background:var(--bg)}
|
||
#param-block .switchrow+.switchrow{margin-top:8px}
|
||
|
||
/* 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/JEAN. 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. */
|
||
#chat{padding:28px max(16px,calc(50% - 380px)) 8px;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:20px;padding:11px 18px;max-width:75%;line-height:1.6}
|
||
#chat .msg.user .label{color:var(--dim)}
|
||
/* Étiquettes VOUS / JEAN : inutiles ici (l'alignement suffit), sauf pour les
|
||
bulles repliables (raisonnement / outils) où le label est le bouton de repli. */
|
||
#chat .msg .label{display:none}
|
||
#chat .msg.collapsible .label{display:block}
|
||
/* 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}
|
||
html[data-hide-stats="1"] #chat .msg .statline{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:14px;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:12px;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. */
|
||
#composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 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) 34%)}
|
||
#chat{padding-bottom:calc(170px + env(safe-area-inset-bottom))}
|
||
#scrollbtn{bottom:calc(180px + env(safe-area-inset-bottom));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:26px 26px 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}
|
||
#send,#stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0}
|
||
#send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600}
|
||
#send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)}
|
||
/* Pied de carte : c'est LUI qui porte l'arrondi bas (26 px, comme en haut) et
|
||
qui clippe la jauge. Un bandeau de 7 px ne peut pas dessiner un rayon de 26 px
|
||
(le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte
|
||
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 26px 26px;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}
|
||
#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}
|
||
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
|
||
button,.addbtn,.mem-search{border-radius:10px}
|
||
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
|
||
.pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
|
||
.pe-inpbtn button{border-radius:0 10px 10px 0}
|
||
.input-btn .ctl{border-radius:10px 0 0 10px}
|
||
.input-btn .btn-in{border-radius:0 10px 10px 0}
|
||
.preset,.gated-block,.lc-mode{border-radius:12px}
|
||
/* 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:12px}
|
||
.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)}
|
||
|
||
</style></head><body>
|
||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||
<div id="backdrop" onclick="toggleSide()"></div>
|
||
<div class="side" id="side">
|
||
<div class="sidehead">
|
||
<h1 id="brand" aria-label="ajean"><span id="brand-a"><svg viewBox="0 0 4 5" shape-rendering="crispEdges" fill="currentColor"><rect x="1" y="0" width="2" height="1"/><rect x="0" y="1" width="1" height="1"/><rect x="3" y="1" width="1" height="1"/><rect x="0" y="2" width="4" height="1"/><rect x="0" y="3" width="1" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="0" y="4" width="1" height="1"/><rect x="3" y="4" width="1" height="1"/></svg></span><span id="brand-word"><svg viewBox="0 0 17 5" shape-rendering="crispEdges" fill="currentColor"><rect x="1" y="0" width="1" height="1"/><rect x="1" y="1" width="1" height="1"/><rect x="1" y="2" width="1" height="1"/><rect x="1" y="3" width="1" height="1"/><rect x="0" y="4" width="1" height="1"/><rect x="3" y="0" width="4" height="1"/><rect x="3" y="1" width="1" height="1"/><rect x="3" y="2" width="3" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="3" y="4" width="4" height="1"/><rect x="9" y="0" width="2" height="1"/><rect x="8" y="1" width="1" height="1"/><rect x="11" y="1" width="1" height="1"/><rect x="8" y="2" width="4" height="1"/><rect x="8" y="3" width="1" height="1"/><rect x="11" y="3" width="1" height="1"/><rect x="8" y="4" width="1" height="1"/><rect x="11" y="4" width="1" height="1"/><rect x="13" y="0" width="1" height="1"/><rect x="16" y="0" width="1" height="1"/><rect x="13" y="1" width="2" height="1"/><rect x="16" y="1" width="1" height="1"/><rect x="13" y="2" width="1" height="1"/><rect x="15" y="2" width="2" height="1"/><rect x="13" y="3" width="1" height="1"/><rect x="16" y="3" width="1" height="1"/><rect x="13" y="4" width="1" height="1"/><rect x="16" y="4" width="1" height="1"/></svg></span><span id="ver"></span></h1>
|
||
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
||
</div>
|
||
<!-- 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:8px;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:8px;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.
|
||
C'est le seul endroit où l'on voit pourquoi un modèle refuse de charger. -->
|
||
<div id="svc-log-box" style="display:none;margin:0 0 10px">
|
||
<div class="row" style="justify-content:space-between;align-items:center">
|
||
<span class="io-cap">Journal du moteur</span>
|
||
<button onclick="loadSvcLog()" title="rafraîchir">↻</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>
|
||
</div>
|
||
<!-- GPU et RAM décrivent la même chose (les ressources de la machine) :
|
||
une seule section, deux blocs de statistique dans la même carte. -->
|
||
<details open><summary><span class="stitle">Machine</span></summary>
|
||
<div class="slist">
|
||
<div id="vram">…</div>
|
||
<div id="ram-details"><div id="ram"></div></div>
|
||
</div>
|
||
</details>
|
||
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
|
||
<div id="cfg" class="slist">…</div>
|
||
</details>
|
||
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter">+</button></summary>
|
||
<div id="presets"></div>
|
||
</details>
|
||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
||
<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>
|
||
<!-- Mémoire / Accès internet / Paramètres : sous-réglages du mode agent, en cartes. Grisés/inertes quand l'agent est off (voir setAgentGate). -->
|
||
<div id="mem-block" class="gated-block">
|
||
<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>
|
||
<label class="switchrow"><span>mode</span>
|
||
<select id="mem-mode" onchange="setMemMode()" class="sctl">
|
||
<option value="always">auto (proactive)</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-caret">▸</span>
|
||
<span class="pages-title">Pages <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>
|
||
<input id="mem-search" class="mem-search" type="search" placeholder="filtrer la mémoire…" oninput="renderMemList()" style="display:none">
|
||
<div id="mem-list" class="mem-list muted" style="font-size:12px"></div>
|
||
</div>
|
||
</div>
|
||
<div id="net-block" class="gated-block">
|
||
<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> via un serveur <b>Crawl4AI</b> que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>). Actifs seulement si le <b>mode agent</b> est actif et que le serveur répond.</span></span></div>
|
||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||
<!-- URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
|
||
« enregistrer » côte à côte n'apportaient rien et brouillaient la carte. -->
|
||
<div class="row" style="margin-top:6px">
|
||
<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" style="margin-top:6px">
|
||
<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 id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||
</div>
|
||
<div id="mcp-block" class="gated-block">
|
||
<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 id="mcp-list" class="mcp-list"></div>
|
||
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
|
||
</div>
|
||
<div id="param-block" class="gated-block">
|
||
<div class="subhead">Paramètres</div>
|
||
<label class="switchrow" style="margin-top:8px"><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>
|
||
</details>
|
||
<details><summary><span class="stitle">Apparence</span></summary>
|
||
<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-side" onchange="toggleView('hide-side')"><span class="slider"></span></span> barre latérale escamotable<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">La barre latérale devient un tiroir <b>même sur ordinateur</b> : elle reste fermée et s'ouvre avec le bouton ☰. Toute la largeur pour la conversation.</span></span></label>
|
||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
||
</div>
|
||
</details>
|
||
<details><summary><span class="stitle">System prompt</span></summary>
|
||
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
|
||
</details>
|
||
<details><summary><span class="stitle">Accès OpenAI</span></summary>
|
||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||
<div class="muted" style="font-size:11px;margin:0 0 10px">Modèle : <code>jean</code></div>
|
||
|
||
<div class="slist pad">
|
||
<div class="io-cap">Adresse locale (réseau)</div>
|
||
<div class="row">
|
||
<input id="oai-url" readonly class="io-input">
|
||
<button onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier">⧉</button>
|
||
</div>
|
||
|
||
</div>
|
||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend votre IA joignable depuis <b>n'importe où</b> via <code>https://<machine>.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite l'accès distant actif.</span></span></label>
|
||
</div>
|
||
<div id="oai-public-wrap" class="slist pad" style="display:none">
|
||
<div class="io-cap">Adresse publique — chiffrée de bout en bout, accessible partout</div>
|
||
<div class="row">
|
||
<input id="oai-public-url" readonly class="io-input">
|
||
<button onclick="copyText(document.getElementById('oai-public-url').value,'adresse publique copiée')" title="copier">⧉</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé <b>redémarre</b> le service.</span></span></div>
|
||
<div class="slist pad">
|
||
<div class="row">
|
||
<input id="oai-key" readonly class="io-input">
|
||
<button id="oai-key-eye" onclick="toggleKeyReveal()" title="afficher / masquer">afficher</button>
|
||
<button onclick="copyApiKey()" title="copier la clé">⧉</button>
|
||
</div>
|
||
<div class="row" style="margin-top:6px">
|
||
<button onclick="apiKeyAction('generate')">générer</button>
|
||
<button onclick="apiKeySet()">définir…</button>
|
||
<button onclick="apiKeyAction('clear')">retirer</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details id="remote-details"><summary><span class="stitle">Accès distant</span><span id="remote-badge" class="sbadge"></span></summary>
|
||
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur Jean depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
|
||
<!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). -->
|
||
<div id="remote-off" class="slist pad">
|
||
<p class="muted" style="font-size:12px;line-height:1.5;margin:6px 0 10px">Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.</p>
|
||
<button onclick="remoteConnect()" style="border-color:var(--accent);color:var(--accent)">Connecter à ajean.link</button>
|
||
</div>
|
||
<!-- Connecté : URL publique + appairage + déconnexion. -->
|
||
<div id="remote-on" style="display:none">
|
||
<div class="slist pad">
|
||
<div class="io-cap">Votre adresse d'accès distant</div>
|
||
<div class="row">
|
||
<input id="remote-url" readonly class="io-input">
|
||
<button onclick="copyText(document.getElementById('remote-url').value,'adresse copiée')" title="copier">⧉</button>
|
||
<button onclick="window.open(document.getElementById('remote-url').value,'_blank')" title="ouvrir">↗</button>
|
||
</div>
|
||
<div id="remote-status" class="muted"></div>
|
||
</div>
|
||
<button id="remote-start" onclick="remoteStart()" style="display:none;margin-top:8px;border-color:var(--accent);color:var(--accent)">démarrer le tunnel</button>
|
||
<div class="subhead" style="margin-top:12px">Première connexion<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">À votre première ouverture du portail distant, confirmez l'<b>empreinte</b> et saisissez le <b>code d'appairage</b> : cela verrouille la boîte noire sur CE serveur (anti-interception). Une seule fois par appareil.</span></span></div>
|
||
<div class="slist pad">
|
||
<div class="row"><button onclick="remotePairCode()">Afficher l'empreinte et le code d'appairage</button></div>
|
||
<div id="remote-pair" class="muted" style="display:none"></div>
|
||
<div class="row"><button onclick="remoteDisconnect()" class="btn-danger-o">déconnecter l'accès distant</button></div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
|
||
<div id="lc-status" class="lc-head muted">…</div>
|
||
<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">conseillé</span></div>
|
||
<div class="lc-mode-d">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é</div>
|
||
<div class="lc-mode-d">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" style="display:none;margin-top:10px">
|
||
<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>
|
||
<!-- Ces trois 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. -->
|
||
<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).</span></span></div>
|
||
<div class="slist pad">
|
||
<div class="row">
|
||
<button onclick="act('start')">démarrer</button>
|
||
<button onclick="act('restart')">redémarrer</button>
|
||
<button onclick="act('stop')">arrêter</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
|
||
<div class="slist pad">
|
||
<div class="row"><button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button></div>
|
||
<div id="upd-msg" class="muted"></div>
|
||
<div class="row">
|
||
<button onclick="loadAll()">↻ refresh</button>
|
||
<button onclick="resetChat()">⌫ clear chat</button>
|
||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="main" style="position:relative">
|
||
<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>
|
||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
|
||
<div id="composer">
|
||
<div id="ctxmeta">
|
||
<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>
|
||
<span style="display:flex;align-items:center;gap:10px;margin-left:auto">
|
||
<span id="ctx-text" class="muted">contexte 0 / 0</span>
|
||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||
</span>
|
||
</div>
|
||
<div id="inputbar">
|
||
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)"></textarea>
|
||
<button id="send" onclick="send()">send</button>
|
||
<button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
|
||
</div>
|
||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||
</div>
|
||
</div>
|
||
<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:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||
<strong>Benchmark</strong>
|
||
<button onclick="closeBenchModal()" style="margin:0">×</button>
|
||
</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>
|
||
<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">
|
||
<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">×</button>
|
||
</div>
|
||
<div class="modal-body mform">
|
||
<div class="muted" style="font-size:12px;line-height:1.5">Compile un fork de llama.cpp depuis un dépôt Git — pour les modèles qui exigent un moteur spécial (quant ternaire, etc.). Un backend personnalisé <b>ne remplace pas</b> le moteur : vous le choisissez ensuite <b>par modèle</b> (bouton ✎ → section Moteur → « backend détecté »).</div>
|
||
<div class="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 & compiler</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
|
||
<div class="modal-card" style="max-width:560px">
|
||
<div class="modal-head">
|
||
<strong id="mcp-modal-title">Serveur MCP</strong>
|
||
<button class="modal-x" onclick="closeMcp()" aria-label="Fermer">×</button>
|
||
</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 @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="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">×</button>
|
||
</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 stack">
|
||
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">lien direct vers un .gguf</span></span>
|
||
<div class="pe-inpbtn">
|
||
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
|
||
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
|
||
</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 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">Les téléchargements, eux, vont toujours dans le dossier AJEAN.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pe-group">
|
||
<div class="pe-gh">Moteur</div>
|
||
<div class="pe-seg">
|
||
<label><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><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><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>
|
||
</div>
|
||
|
||
<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 = tout sur le GPU</span></span>
|
||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="number" min="0" step="1" placeholder="999" oninput="cfgWriteKey('NGL', 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">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>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Raisonnement<span class="pe-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':'')"><span class="pe-sl"></span></label></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>
|
||
|
||
<details class="pe-adv" style="margin-top:8px">
|
||
<summary>Réglages avancés</summary>
|
||
<div class="pe-adv-body" style="padding:0">
|
||
<div class="pe-list" style="border:none;border-radius:0">
|
||
<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">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">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">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>
|
||
</details>
|
||
</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 <code>.env</code><span class="pe-sub">configuration brute — pour les réglages avancés</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>
|
||
<div class="pe-foot">
|
||
<div class="foot-l">
|
||
<button id="m-del" class="pe-btn ghost-danger" onclick="delItem()">supprimer</button>
|
||
<label id="m-del-model-wrap" class="pe-delmodel" style="display:none"><input type="checkbox" id="m-del-model"> + le modèle .gguf</label>
|
||
</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('jean.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('jean.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 (ajean, 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('jean-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('jean-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).
|
||
const VIEW_OPTS=[
|
||
{id:'hide-reasoning', label:'masquer le raisonnement'},
|
||
{id:'hide-tools', label:"masquer les appels d'outils"},
|
||
{id:'fold-tools', label:'garder les bulles repliées'},
|
||
{id:'hide-side', label:'barre latérale escamotable'},
|
||
{id:'hide-stats', label:'masquer la vitesse de génération'},
|
||
];
|
||
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
|
||
function applyView(id, on){
|
||
document.documentElement.setAttribute('data-'+id, on?'1':'0');
|
||
try{ localStorage.setItem('jean-'+id, on?'1':'0'); }catch(e){}
|
||
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
|
||
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
|
||
if(id==='hide-side' && !on){
|
||
document.getElementById('side').classList.remove('open');
|
||
document.getElementById('backdrop').classList.remove('open');
|
||
document.body.classList.remove('drawer-open');
|
||
}
|
||
}
|
||
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
|
||
function initView(){
|
||
VIEW_OPTS.forEach(o=>{
|
||
let v=null; try{ v=localStorage.getItem('jean-'+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 jean (/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('jean-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 (ajean, 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('jean.sys') || ''; loadSys(); restoreChat(); });
|
||
// 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||'');
|
||
}
|
||
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';
|
||
function askResolve(ok){
|
||
if(!_askResolver) return;
|
||
const r=_askResolver; _askResolver=null;
|
||
document.getElementById('ask-modal').style.display='none';
|
||
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 jean] 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||{};
|
||
_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';
|
||
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);
|
||
document.getElementById('ask-modal').style.display='flex';
|
||
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('jean.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 Jean) 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 AJEAN ouverts ?)'); }
|
||
throw e;
|
||
}
|
||
finally{ if(timer) clearTimeout(timer); }
|
||
if(r.status === 401){
|
||
const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'});
|
||
if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
|
||
}
|
||
return r;
|
||
}
|
||
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).
|
||
let cls='err', txt=s.state;
|
||
if(s.active && s.health){ cls='ok'; txt='prêt'; }
|
||
else if(s.load_error){ cls='err'; txt='modèle incompatible'; }
|
||
else if(s.active){ cls='loading'; txt='chargement…'; }
|
||
el.className='statuspill '+cls;
|
||
el.innerHTML='<span class="dot"></span>'+txt+' <span class="port">:'+s.port+'</span>';
|
||
MODEL_READY = !!(s.active && s.health);
|
||
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 — replié par défaut, on l'ouvre en cliquant la pastille.
|
||
function toggleSvcLog(){
|
||
const box=document.getElementById('svc-log-box');
|
||
if(!box) return;
|
||
const show = box.style.display==='none';
|
||
box.style.display = show ? '' : 'none';
|
||
if(show){ loadSvcLog(); showPaths(); }
|
||
}
|
||
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');
|
||
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='AJEAN 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],['Configuration',p.config],['Fichiers créés par l\'IA',p.workspace],['Mémoire',p.memory],['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;
|
||
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)';
|
||
document.getElementById('ctx-text').textContent='contexte '+CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
|
||
// 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';
|
||
}
|
||
async function loadVram(){
|
||
const gpus=await jget('/api/vram');
|
||
// Bloc de statistique : intitulé + valeur sur une ligne, jauge, détail dessous.
|
||
// Même gabarit que la RAM (voir .stat dans le CSS) — le HTML libre d'avant
|
||
// collait aux bords de la carte.
|
||
document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{
|
||
const pct=Math.round(g.used*100/g.total);
|
||
return '<div class="stat"><div class="stat-h"><span class="stat-n">'+g.name+'</span>'+
|
||
'<span class="stat-v">'+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB</span></div>'+
|
||
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
|
||
'<div class="stat-s">GPU '+g.util+' % · '+g.temp+' °C</div></div>';
|
||
}).join('') || '<div class="stat"><span class="stat-s">(pas de GPU)</span></div>';
|
||
}
|
||
async function loadRam(){
|
||
const m=await jget('/api/ram');
|
||
const box=document.getElementById('ram-details');
|
||
if(!m || !m.total){ if(box) box.style.display='none'; return; }
|
||
if(box) box.style.display='';
|
||
const pct=Math.round(m.used*100/m.total);
|
||
document.getElementById('ram').innerHTML =
|
||
'<div class="stat"><div class="stat-h"><span class="stat-n">Mémoire vive</span>'+
|
||
'<span class="stat-v">'+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB</span></div>'+
|
||
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
|
||
'<div class="stat-s">'+pct+' % utilisée</div></div>';
|
||
}
|
||
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,'"')+'">'+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;
|
||
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));
|
||
}
|
||
['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('');
|
||
}
|
||
async function loadPresets(){
|
||
const p=await jget('/api/presets');
|
||
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' : '';
|
||
const cont=document.getElementById('presets');
|
||
cont.innerHTML='';
|
||
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
|
||
p.forEach((x,i)=>{
|
||
const row=document.createElement('div');
|
||
row.className='preset'+(x.active?' active':'');
|
||
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';
|
||
nm.textContent=(x.active?'● ':'')+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');
|
||
edit.className='preset-edit'; edit.title='éditer'; edit.textContent='✎';
|
||
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 « jean » → « ajean ».
|
||
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').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;
|
||
function renderInternet(s){
|
||
internetOn = !!s.enabled;
|
||
document.getElementById('internet-toggle').checked = internetOn;
|
||
if(document.activeElement !== document.getElementById('crawl-url'))
|
||
document.getElementById('crawl-url').value = s.url || '';
|
||
// Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les
|
||
// outils web ne sont pas proposés au modèle dans ce cas).
|
||
if(internetOn && s.url && !s.reachable) setBadge('internet-badge','warn','injoignable');
|
||
else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif');
|
||
// 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(!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 || '';
|
||
// URL de l'endpoint : llama-server tourne sur d.port (≠ port de l'UI web).
|
||
// On prend l'hôte annoncé par le serveur (IP LAN détectée côté Go) : dans le
|
||
// tunnel ajean.link, location.hostname serait le domaine du relais (faux) —
|
||
// l'accès OpenAI reste TOUJOURS l'adresse locale de la machine.
|
||
const host = d.host || location.hostname;
|
||
document.getElementById('oai-url').value = 'http://'+host+':'+d.port+'/v1';
|
||
// Endpoint PUBLIC (ajean.link) : affiché seulement si l'accès public est activé.
|
||
const tg = document.getElementById('oai-public-toggle');
|
||
if(tg) tg.checked = !!d.oai_public;
|
||
const pubWrap = document.getElementById('oai-public-wrap');
|
||
if(d.oai_public && d.machine){
|
||
document.getElementById('oai-public-url').value = 'https://'+d.machine+'.oai.ajean.link/v1';
|
||
pubWrap.style.display = '';
|
||
} else {
|
||
pubWrap.style.display = 'none';
|
||
}
|
||
const inp=document.getElementById('oai-key');
|
||
if(!d.set){ inp.value='(aucune clé — serveur 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';
|
||
}
|
||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||
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 toggleOAIPublic(){
|
||
const cb = document.getElementById('oai-public-toggle');
|
||
const on = cb.checked;
|
||
// L'accès OpenAI public passe par ajean.link (<machine>.oai.ajean.link) : il exige
|
||
// que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique.
|
||
if(on){
|
||
let linked = false;
|
||
try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){}
|
||
if(!linked){
|
||
cb.checked = false;
|
||
await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'});
|
||
return;
|
||
}
|
||
}
|
||
await jpost('/api/oai/public', {enabled:on});
|
||
toast(on ? 'accès public activé' : 'accès public coupé');
|
||
loadApiKey();
|
||
}
|
||
async function apiKeySet(){
|
||
const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'});
|
||
if(!k || !k.trim()) return;
|
||
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();
|
||
if(on && !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 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(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); }
|
||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
|
||
function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; }
|
||
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…');
|
||
const r=await jpost('/api/switch',{n:n});
|
||
toast(r.ok?'switched':'erreur'); setTimeout(loadAll,2000);
|
||
}
|
||
// 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').
|
||
preset: {label:'Preset', param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()},
|
||
mem: {label:'Page', param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()},
|
||
};
|
||
async function openItem(kind, key){
|
||
const K = KINDS[kind];
|
||
const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||''));
|
||
const d = await r.json();
|
||
editingKind = kind; editingKey = key || '';
|
||
const display = d.name || key || '';
|
||
document.getElementById('modal-title').textContent = key ? (K.label + ' · ' + display) : ('Nouveau ' + K.label.toLowerCase());
|
||
document.getElementById('m-name').value = display;
|
||
document.getElementById('m-content').value = d.content || '';
|
||
document.getElementById('m-del').style.display = key ? 'inline-block' : 'none';
|
||
// Model picker is preset-only: it edits the MODEL= line of config.env.
|
||
const modelRow = document.getElementById('m-model-row');
|
||
const settingsRow = document.getElementById('m-settings-row');
|
||
const delModelWrap = document.getElementById('m-del-model-wrap');
|
||
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';
|
||
document.getElementById('m-hf-url').value = '';
|
||
resetDlUI();
|
||
attachDownload(); // téléchargement encore en cours côté serveur ?
|
||
document.getElementById('m-del-model').checked = false;
|
||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||
populateSettings();
|
||
delModelWrap.style.display = key ? 'inline-flex' : 'none';
|
||
// 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);
|
||
await Promise.all([populateBackend(), populateModelPicker()]);
|
||
} else {
|
||
modelRow.style.display = 'none';
|
||
settingsRow.style.display = 'none';
|
||
delModelWrap.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 = '';
|
||
}
|
||
document.getElementById('modal').style.display = 'flex';
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
// Read the current MODEL= value out of the textarea, handling quotes / spaces.
|
||
function currentModelInTextarea(){
|
||
const txt = document.getElementById('m-content').value;
|
||
const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m);
|
||
return m ? m[1].trim() : '';
|
||
}
|
||
// É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 =>
|
||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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, JEAN_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;
|
||
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||
}
|
||
html += groups.length > 1
|
||
? '<optgroup label="'+escHtml(g.home ? 'dossier jean' : 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 : JEAN_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');
|
||
}
|
||
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';
|
||
info.textContent = '(' + n + (x.home ? ', dossier jean' : '') + ')';
|
||
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()]);
|
||
}
|
||
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()]);
|
||
}
|
||
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);
|
||
}
|
||
|
||
// 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(){
|
||
const txt = document.getElementById('m-content').value;
|
||
const m = txt.match(/^\s*BIN\s*=\s*"?([^"\n]*)"?\s*$/m);
|
||
return m ? m[1].trim() : '';
|
||
}
|
||
// 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;
|
||
}
|
||
let beFastPath = '', beOptPath = '';
|
||
async function populateBackend(){
|
||
// Chemins des deux moteurs gérés + liste des backends détectés (dossier jean).
|
||
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
|
||
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
|
||
beOptPath = lc.bin || '';
|
||
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
|
||
// le dossier backends de jean (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';
|
||
if(sameBinPath(cur, beFastPath)) 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;
|
||
}
|
||
function toggleBackendCustom(mode){
|
||
document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none';
|
||
}
|
||
function onBackendMode(mode){
|
||
toggleBackendCustom(mode);
|
||
if(mode === 'fast'){
|
||
if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; }
|
||
setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé');
|
||
} else if(mode === 'opt'){
|
||
if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; }
|
||
setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé');
|
||
}
|
||
// 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);
|
||
}
|
||
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é');
|
||
}
|
||
// Read/write the QUANT= override line in the preset textarea.
|
||
function currentQuantInTextarea(){
|
||
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=\s*"?([^"\n]*)"?\s*$/mi);
|
||
return m ? m[1].trim() : '';
|
||
}
|
||
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){
|
||
const m = document.getElementById('m-content').value.match(new RegExp('^[ \\t]*'+key+'[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$','m'));
|
||
return m ? m[1].trim() : '';
|
||
}
|
||
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'));
|
||
set('s-moe', eaGetValued('--n-cpu-moe'));
|
||
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
|
||
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING')));
|
||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||
chk('s-mlock', eaHasFlag('--mlock'));
|
||
chk('s-nommap', eaHasFlag('--no-mmap'));
|
||
}
|
||
// 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(){ document.getElementById('modal').style.display = 'none'; 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;
|
||
const delModel = editingKind==='preset' && document.getElementById('m-del-model').checked;
|
||
let msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?';
|
||
if(delModel) msg += '\n\n⚠ Le fichier .gguf du modèle sera AUSSI supprimé du disque (irréversible).';
|
||
if(!await askConfirm(msg, {title:'Suppression', okText:'Supprimer', danger:true})) return;
|
||
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;
|
||
}
|
||
async function startDownload(){
|
||
const url = document.getElementById('m-hf-url').value.trim();
|
||
if(!url){ toast('colle un lien .gguf'); return; }
|
||
const e = dlEls();
|
||
e.btn.disabled = true;
|
||
e.prog.style.display = 'block';
|
||
e.prog.textContent = 'démarrage…';
|
||
e.bar.style.display = 'block';
|
||
e.bar.className = 'pe-bar indet';
|
||
e.bar.firstElementChild.style.width = '';
|
||
const r = await jpost('/api/models/download', {url});
|
||
if(!r.ok){
|
||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
||
e.bar.style.display = 'none'; e.btn.disabled=false; 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'; stop(); return;
|
||
}
|
||
if(st.error){
|
||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
|
||
e.bar.style.display = 'none'; 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 populateModelPicker();
|
||
document.getElementById('m-model').value = fname; onPickModel();
|
||
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');
|
||
}
|
||
}
|
||
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+' — '+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);
|
||
}
|
||
// 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');
|
||
}
|
||
addEventListener('resize', syncGutter);
|
||
addEventListener('DOMContentLoaded', syncGutter);
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
function addMsg(role, text){
|
||
const el=document.createElement('div');
|
||
el.className='msg '+role;
|
||
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>';
|
||
}
|
||
el.querySelector('.body').textContent=text;
|
||
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 (prefill / decode). Les étiquettes VOUS/JEAN
|
||
// 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. Masquée
|
||
// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
|
||
function setStats(el, text){
|
||
if(!el) return;
|
||
let s = el.querySelector(':scope > .statline');
|
||
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);
|
||
}
|
||
s.textContent = text;
|
||
}
|
||
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(text); markNotices(b); addCopyButtons(b); scrollMaybe(); }
|
||
// 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 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;
|
||
}
|
||
// 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();
|
||
}
|
||
// 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);
|
||
});
|
||
}
|
||
// Nouvelle conversation POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
|
||
function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); toast('nouvelle conversation'); }
|
||
// 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('jean.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');
|
||
}
|
||
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 jean. 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;
|
||
function clearPending(){ if(PENDING){ PENDING.remove(); PENDING=null; } }
|
||
function addPending(text){
|
||
clearPending();
|
||
PENDING=addMsg('user', text);
|
||
PENDING.classList.add('pending');
|
||
const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…';
|
||
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');
|
||
const l=PENDING.querySelector('.label'); if(l) l.textContent='user';
|
||
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 }; }
|
||
newTurn();
|
||
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();
|
||
}
|
||
function killTyping(){ if(!T.typingEl) return; if(simpleMode()) return; removeTyping(); }
|
||
function showTyping(){ if(!simpleMode()) 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(parts).join(' · '));
|
||
else setStats(el, parts.join(' · '));
|
||
}
|
||
// 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;
|
||
if(secs>0.05 && n>1){ setLabel(el, role+' · '+n+' tok · '+(n/secs).toFixed(1)+' tok/s'); }
|
||
else { setLabel(el, role+' · '+n+' tok'); }
|
||
}
|
||
// 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(); }
|
||
function syncSendBtn(){
|
||
document.getElementById('send').style.display=busy?'none':'inline-block';
|
||
document.getElementById('stop').style.display=busy?'inline-block':'none';
|
||
}
|
||
// 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;
|
||
if(d.caught_up){
|
||
// 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'; }
|
||
return; }
|
||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
|
||
if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
|
||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
|
||
if(d.error){ 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;
|
||
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){
|
||
killTyping(); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
|
||
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);
|
||
if(!tu.done) showTyping();
|
||
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||
return; }
|
||
if(d.drop_reasoning){
|
||
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();
|
||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; 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){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; }
|
||
showTyping(); T.fullReason+=d.reasoning_content; renderBody(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',''); T.fullContent=''; }
|
||
if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; }
|
||
T.fullContent+=d.content; renderBody(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 Jean 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;
|
||
const ta=document.getElementById('input'); const text=ta.value.trim();
|
||
if(!text) 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); };
|
||
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,ctx_used:CTX_USED})});
|
||
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;
|
||
|
||
const head = document.getElementById('lc-status');
|
||
if(!fastInstalled && !optInstalled){
|
||
head.innerHTML = 'Installez le moteur d\'IA. Le <b>précompilé</b> convient à presque tout le monde.';
|
||
} else {
|
||
head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton <b>✎</b>).';
|
||
}
|
||
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){
|
||
document.getElementById('lc-details').open = true;
|
||
lcStartPolling();
|
||
}
|
||
}
|
||
|
||
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(){
|
||
document.getElementById('lc-custom-modal').style.display = 'flex';
|
||
document.getElementById('lc-custom-url').value = '';
|
||
document.getElementById('lc-custom-name').value = '';
|
||
loadCustomBackends();
|
||
}
|
||
function closeCustomBackends(){ document.getElementById('lc-custom-modal').style.display = 'none'; }
|
||
|
||
// 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,'"')+'">'+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();
|
||
document.getElementById('lc-details').open = true;
|
||
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;
|
||
lcBusy(true);
|
||
if(lcPoll) clearInterval(lcPoll);
|
||
lcPoll = setInterval(lcPollJob, 1000);
|
||
lcPollJob();
|
||
}
|
||
|
||
async function lcPollJob(){
|
||
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;
|
||
if(j.running){
|
||
phaseEl.innerHTML = '<span class="lc-spin">⏳</span> <span>'+String(j.phase||'…').replace(/[<>&]/g,'')+'</span>';
|
||
return;
|
||
}
|
||
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) || [];
|
||
const enabled = mcpServers.filter(s=>s.enabled).length;
|
||
if(enabled) setBadge('mcp-badge', true, enabled+(enabled>1?' actifs':' actif'));
|
||
else if(mcpServers.length) setBadge('mcp-badge', false, 'aucun actif');
|
||
else 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.textContent = '✎';
|
||
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é');
|
||
}
|
||
|
||
// 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);
|
||
document.getElementById('mcp-modal').style.display = 'flex';
|
||
}
|
||
|
||
// 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(){ document.getElementById('mcp-modal').style.display = 'none'; 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é');
|
||
}
|
||
// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
|
||
// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
|
||
// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
|
||
// l'agent local (/api/link/connect) qui fait le `jean link` (token + service).
|
||
|
||
const AJEAN_APP_ORIGIN = 'https://app.ajean.link';
|
||
|
||
function renderRemote(d){
|
||
const off = document.getElementById('remote-off'), on = document.getElementById('remote-on');
|
||
const badge = document.getElementById('remote-badge');
|
||
if(!d || !d.linked){
|
||
// Serveur jamais lié : on l'affiche explicitement plutôt que de laisser la
|
||
// pastille vide, qui se lisait comme « je ne sais pas ».
|
||
off.style.display=''; on.style.display='none';
|
||
setBadge(badge, false, 'non connecté');
|
||
return;
|
||
}
|
||
off.style.display='none'; on.style.display='';
|
||
document.getElementById('remote-url').value = d.machineURL || '';
|
||
const st = document.getElementById('remote-status');
|
||
if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; }
|
||
else { st.textContent='○ tunnel arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; }
|
||
const sb = document.getElementById('remote-start');
|
||
if(sb){ sb.style.display = d.active ? 'none' : ''; }
|
||
setBadge(badge, d.active ? true : 'warn', d.active ? 'connecté' : 'arrêté');
|
||
}
|
||
|
||
async function loadRemote(){
|
||
// Le panneau ne vaut qu'en LOCAL : il pilote /api/link/* que le tunnel bloque
|
||
// (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache.
|
||
const det = document.getElementById('remote-details');
|
||
if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; }
|
||
// Statut injoignable : on retombe sur « non connecté » plutôt que de garder
|
||
// l'affichage précédent, qui pourrait annoncer « connecté » à tort.
|
||
try{ renderRemote(await jget('/api/link/status')); }catch(e){ renderRemote(null); }
|
||
}
|
||
|
||
// Ouvre la popup de connexion et attend la clé renvoyée par postMessage.
|
||
function remoteConnect(){
|
||
const params = new URLSearchParams({ origin: window.location.origin, host: window.location.hostname || 'ce serveur' });
|
||
const url = AJEAN_APP_ORIGIN + '/connect.html?' + params.toString();
|
||
const pop = window.open(url, 'ajean-connect', 'width=440,height=640');
|
||
if(!pop){ toast('autorisez les popups pour connecter ajean.link'); return; }
|
||
|
||
async function onMsg(ev){
|
||
// Anti-usurpation : n'accepte QUE des messages du portail ajean.link.
|
||
if(ev.origin !== AJEAN_APP_ORIGIN) return;
|
||
const d = ev.data || {};
|
||
if(d.type !== 'ajean-link' || !d.token) return;
|
||
window.removeEventListener('message', onMsg);
|
||
try{
|
||
const r = await jpost('/api/link/connect', { token: d.token });
|
||
if(r && r.linked){
|
||
toast('✓ accès distant connecté');
|
||
if(r.serviceErr){ toast('token enregistré (service : '+r.serviceErr+')'); }
|
||
loadRemote();
|
||
} else {
|
||
toast((r && r.error) || 'échec de la connexion');
|
||
}
|
||
}catch(e){ toast('erreur : '+e); }
|
||
}
|
||
window.addEventListener('message', onMsg);
|
||
}
|
||
|
||
// Relance le tunnel avec la clé déjà enregistrée (sans repasser par la popup).
|
||
async function remoteStart(){
|
||
const b = document.getElementById('remote-start');
|
||
if(b){ b.disabled = true; b.textContent = 'démarrage…'; }
|
||
try{
|
||
const r = await jpost('/api/link/start', {});
|
||
if(r && r.active){ toast('✓ tunnel démarré'); }
|
||
else { toast((r && r.error) || 'le tunnel n\'a pas démarré'); }
|
||
}catch(e){ toast('erreur : '+e); }
|
||
if(b){ b.disabled = false; b.textContent = 'démarrer le tunnel'; }
|
||
loadRemote();
|
||
}
|
||
|
||
async function remoteDisconnect(){
|
||
const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'});
|
||
if(!ok) return;
|
||
try{ await jpost('/api/link/disconnect', {}); toast('accès distant coupé'); loadRemote(); }
|
||
catch(e){ toast('erreur : '+e); }
|
||
}
|
||
|
||
async function remotePairCode(){
|
||
const box = document.getElementById('remote-pair');
|
||
box.style.display=''; box.textContent='génération du code…';
|
||
try{
|
||
const r = await jpost('/api/link/paircode', {});
|
||
if(r && r.code){
|
||
box.innerHTML = 'Empreinte : <b>'+(r.fingerprint||'—')+'</b><br>Code d\'appairage (valable 10 min, usage unique) : <b>'+r.code+'</b>';
|
||
} else {
|
||
box.textContent = (r && r.error) || 'code indisponible';
|
||
}
|
||
}catch(e){ box.textContent='erreur : '+e; }
|
||
}
|
||
</script></body></html>
|