mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Les fichiers du chat vivaient dans un pot commun : on changeait de discussion et on revoyait les mêmes pièces jointes, et supprimer une discussion laissait derrière elle tout ce qu'on y avait déposé ou fait écrire à l'agent (seules ses captures, déjà rangées par identifiant, partaient avec). Chaque discussion a désormais son dossier, <workspace>/discussions/<id>/ : - les dépôts (uploads/), les captures (captures/) et ce que l'agent écrit y atterrissent ; le shell et les chemins relatifs du modèle y sont résolus ; - le panneau Fichiers s'ouvre sur ce dossier et n'en sort pas, et se redessine quand la discussion change (bascule ou vidage, signalés par le flux SSE) ; - supprimer une discussion — ou la vider — emporte ses fichiers. Les deux gestes le disent maintenant avant de demander confirmation ; « clear chat » en demandait aucune. La racine du dossier de travail reste la borne de sécurité : les liens des anciens messages (uploads/x.pdf, captures/<id>/y.jpg) continuent d'ouvrir leur fichier par un chemin de repli. Au démarrage, une migration range les captures dans le dossier de leur discussion et rend chaque dépôt à la discussion qui le mentionne dans son journal ; ce que personne ne réclame reste à la racine, atteignable par le bouton « hors discussion » du panneau, qui disparaît une fois le ménage fait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
6366 lines
370 KiB
HTML
6366 lines
370 KiB
HTML
<!doctype html>
|
||
<!-- GÉNÉRÉ depuis ui/src/ par tools/assemble-ui (go generate ./internal/loki) — NE PAS ÉDITER index.html DIRECTEMENT -->
|
||
<html lang="fr"><head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||
<!-- PWA standalone : "Ajouter à l'écran d'accueil" sur iPhone → app plein écran
|
||
SANS la barre d'onglets Safari (indispensable pour enregistrer des démos). -->
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="Loki">
|
||
<title>Loki</title>
|
||
<!-- Favicon (même marque « loki » que la landing/portail) + icône PWA (écran d'accueil
|
||
iOS). SVG inline pour le favicon, PNG 180×180 en data-URI pour apple-touch-icon
|
||
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%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('loki-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('loki-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
|
||
<style>
|
||
/* ===== Polices =====================================================
|
||
Bricolage Grotesque (titres et texte) + IBM Plex Mono (chiffres, chemins,
|
||
code). Servies par Loki (/fonts/, embarquées dans le binaire) et NON par
|
||
Google Fonts : une instance locale ou coupée d'internet doit s'afficher
|
||
correctement, sans qu'un tiers apprenne qui consulte l'interface.
|
||
swap : le texte s'affiche tout de suite dans la police de repli, puis bascule
|
||
— jamais de blanc au chargement. */
|
||
@font-face{font-family:'Bricolage Grotesque';src:url('/fonts/bricolage.woff2') format('woff2');font-weight:400 700;font-display:swap;font-style:normal}
|
||
@font-face{font-family:'IBM Plex Mono';src:url('/fonts/plexmono-400.woff2') format('woff2');font-weight:400;font-display:swap;font-style:normal}
|
||
@font-face{font-family:'IBM Plex Mono';src:url('/fonts/plexmono-500.woff2') format('woff2');font-weight:500;font-display:swap;font-style:normal}
|
||
|
||
/* ===== Palette « Terre » ===========================================
|
||
Bruns chauds, vert sauge et argile. Deux variantes — claire par défaut,
|
||
sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI passent
|
||
par ces variables ; rien d'autre ne code une couleur en dur.
|
||
|
||
Rôles : --accent = sauge (sélection, marche, liens) · --warn = argile
|
||
(chiffres, mesures, avertissements) · --err = terre cuite (arrêt, danger).
|
||
--row-bg / --code-bg sont les deux fonds encastrés d'un panneau. */
|
||
:root{color-scheme:light;
|
||
--font:'Bricolage Grotesque',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
|
||
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||
--bg:#f3efe8;--panel:#ffffff;--bubble:#efe9df;--border:rgba(44,38,34,.12);
|
||
--text:#2c2622;--dim:#7a7069;
|
||
--accent:#5f7f52;--accent-bg:rgba(95,127,82,.12);
|
||
--row-bg:#f7f4ee;--row-bg-on:#f4f1ea;
|
||
--ok:#5f7f52;--warn:#a9662f;--err:#c05a3f;--mag:#a9662f;
|
||
--code-bg:#f7f4ee;--danger-bg:#f8e9e4;--warn-bg:#f6efe4;
|
||
--add:#5f7f52;--add-bg:rgba(95,127,82,.12);--del:#c05a3f;--del-bg:rgba(192,90,63,.12);
|
||
--shadow:0 1px 2px rgba(44,38,34,.06)}
|
||
[data-theme="dark"]{color-scheme:dark;
|
||
--bg:#211d1a;--panel:#2b2622;--bubble:#38312a;--border:rgba(236,229,221,.12);
|
||
--text:#ece5dd;--dim:#a99d92;
|
||
--accent:#9ab68c;--accent-bg:rgba(154,182,140,.15);
|
||
--row-bg:#332d28;--row-bg-on:#38312a;
|
||
--ok:#9ab68c;--warn:#d2a07a;--err:#c9705c;--mag:#d2a07a;
|
||
--code-bg:#211d1a;--danger-bg:#3a2621;--warn-bg:#332b23;
|
||
--add:#9ab68c;--add-bg:rgba(154,182,140,.15);--del:#c9705c;--del-bg:rgba(201,112,92,.15);
|
||
--shadow:none}
|
||
/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien,
|
||
résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors
|
||
de notre charte et impossible à styler : on le coupe. Nos états :active/:focus
|
||
restent, eux, gérés élément par élément. */
|
||
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||
/* ⚠️ overflow:hidden sur html/body est INTERDIT EN PWA (tenté puis retiré le
|
||
2026-07-28) : en PWA iOS, si le document ne peut plus défiler, WebKit décale
|
||
TOUTE la mise en page à l'ouverture du clavier pour montrer le champ actif —
|
||
la carte de saisie se retrouve plaquée en haut de l'écran. Le rebond
|
||
élastique du document est le prix à payer ; il est cosmétique, le clavier
|
||
ne l'est pas.
|
||
Laisser le document défiler se paie cher, et EN PWA AUSSI : la coque y fait
|
||
956 pt (obligatoire pour que la carte atteigne le bas de l'écran) alors que la
|
||
fenêtre de mise en page n'en fait que 894 → il reste 62 pt de document
|
||
défilable en permanence, même avec un fil VIDE, et la carte de saisie — posée
|
||
en bottom:0 dans le body — part avec au lieu de rester collée en bas.
|
||
On coupe donc le défilement du document PARTOUT (2026-07-29). Le fil, lui,
|
||
défile toujours dans #chat, qui borne son rebond (overscroll:contain).
|
||
⚠️ SI LE CLAVIER iOS SE REMET À TOUT DÉCALER EN PWA, c'est ce overflow:hidden
|
||
qu'il faut rouvrir en premier — c'était le compromis d'origine. */
|
||
/* Hauteur de la coque : elle DIFFÈRE entre PWA et navigateur, il faut les deux.
|
||
- PWA (écran d'accueil) : 100vh, et SURTOUT ni 100dvh ni 100%. Mesuré sur
|
||
iPhone (2026-07-28) : la vue web couvre tout l'écran (956 pt — le contenu
|
||
passe bien sous l'horloge), mais la fenêtre de mise en page ne fait que
|
||
894 pt et reste calée en haut. Or 100dvh ET 100% valent tous deux 894 → il
|
||
restait une bande morte de 62 pt (= la zone sûre du haut) en bas de l'écran,
|
||
sous la carte de saisie. 100vh se réfère au GRAND viewport (956) : la seule
|
||
unité juste dans ce cas.
|
||
- Navigateur (barre d'URL + barre d'outils visibles) : 100vh vaut justement ce
|
||
GRAND viewport, donc la coque dépasse la zone réellement visible → le
|
||
document défile alors que le fil est vide, et le bas de la carte de saisie
|
||
(modèle / contexte) est coupé. Ici c'est 100dvh qu'il faut : la hauteur
|
||
visible du moment.
|
||
D'où : 100dvh par défaut, et 100vh UNIQUEMENT en PWA.
|
||
⚠️ Le tri se fait sur l'attribut html[data-pwa] (pose par le script du <head>),
|
||
PAS sur le media `display-mode:standalone` : WebKit adosse ce media au `display`
|
||
du MANIFESTE, or cette app n'en a pas (elle vit en apple-mobile-web-app-capable).
|
||
En PWA iOS il repond donc `browser`, la branche navigateur s'appliquait, et la
|
||
bande morte de 62 pt sous la carte de saisie etait de retour (constate le
|
||
2026-07-29).
|
||
⚠️ Ne pas y ajouter overflow:hidden : voir plus haut, ça casse le clavier iOS. */
|
||
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;overscroll-behavior:none;display:flex;font-size:14px}
|
||
html[data-pwa="1"] body{height:100vh}
|
||
/* Seul #chat défile ; le document, lui, est figé (cf. l'avertissement ci-dessus).
|
||
Exception PWA : on lui rend son défilement, sinon le clavier iOS casse tout. */
|
||
html,body{overflow:hidden}
|
||
/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne
|
||
pas passer sous l'encoche / la barre système / l'indicateur d'accueil. Les
|
||
env(safe-area-inset-*) valent 0 hors PWA, donc aucun impact ailleurs.
|
||
⚠️ PAS de padding-bas ici : le composer est posé en bottom:0 DANS le body, donc
|
||
une marge basse le décollerait du bord de l'écran et laisserait une bande de
|
||
fond visible sous la carte (visible seulement en PWA, où l'inset vaut ~34 px).
|
||
L'indicateur d'accueil est esquivé bloc par bloc, en padding INTERNE. */
|
||
body{padding:env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left)}
|
||
/* Listes déroulantes : la LISTE ouverte n'hérite pas du fond de son <select>.
|
||
Plusieurs de nos select sont en background:transparent + color clair (chevron
|
||
discret) : en thème sombre, le navigateur peignait donc du texte clair sur le
|
||
fond blanc par défaut de la liste — illisible (signalé sur « Cache KV », mais
|
||
ça touchait TOUTES les listes déroulantes en sombre). On habille donc les
|
||
<option> eux-mêmes ; color-scheme (posé sur <html>) ne suffit pas dès que le
|
||
select porte des couleurs explicites. */
|
||
select{color-scheme:inherit}
|
||
option,optgroup{background:var(--panel);color:var(--text)}
|
||
.side{width:320px;border-right:1px solid var(--border);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));overflow-y:auto;flex-shrink:0}
|
||
.main{flex:1;display:flex;flex-direction:column;min-width:0}
|
||
#menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer}
|
||
#backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10}
|
||
/* Pastille « installation en cours » : suit exactement les règles d'affichage
|
||
du bouton ☰ (elle ne sert que quand le panneau est en tiroir, donc caché). */
|
||
#lc-chip{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(58px + env(safe-area-inset-left));z-index:20;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--panel);border:1px solid var(--accent);color:var(--accent);height:40px;padding:0 12px;border-radius:6px;font-size:12px;cursor:pointer}
|
||
#lc-chip.failed{border-color:var(--err);color:var(--err)}
|
||
@media (max-width:720px){
|
||
.side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left))}
|
||
.side.open{transform:translateX(0)}
|
||
#menubtn,#backdrop.open{display:block}
|
||
#lc-chip:not([hidden]){display:block}
|
||
body.drawer-open #menubtn,body.drawer-open #lc-chip{display:none}
|
||
.main{width:100%}
|
||
#chat{padding:60px 8px 12px}
|
||
.msg{max-width:92%}
|
||
#chat .msg.assistant,#chat .msg.reasoning{max-width:100%}
|
||
#inputbar{padding:8px;gap:4px}
|
||
#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}
|
||
/* …sauf quand elle annonce que le modèle charge : là c'est une information
|
||
d'état, pas un rappel de raccourci. */
|
||
#sendhint.waiting{display:block}
|
||
}
|
||
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
||
@media (min-width:721px){
|
||
.side{width:380px}
|
||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius: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 : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A »
|
||
reste un bloc à part : il apparaît en glissant quand le mode agent est actif. */
|
||
#brand{display:inline-flex;align-items:center}
|
||
/* 17px : le logo pixel-art était minuscule à 10px, illisible sur un écran dense.
|
||
Multiple impair volontaire — la grille du SVG (5 px de haut) reste nette. */
|
||
#brand svg{height:17px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
|
||
#brand-word{display:inline-flex;color:var(--accent)}
|
||
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:7px;letter-spacing:0;align-self:flex-end;line-height:1;position:relative;top:1px}
|
||
.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
|
||
/* Attribution : Loki est un fork d'AJEAN (MIT). Discrète mais permanente, et le
|
||
nom du projet d'origine renvoie à son dépôt. */
|
||
.brand-fork{font-size:10px;color:var(--dim);margin:0 0 14px;line-height:1.3}
|
||
.brand-fork a{color:var(--dim);text-decoration:underline;text-underline-offset:2px}
|
||
.brand-fork a:hover{color:var(--accent)}
|
||
/* Discussions : mêmes lignes que les presets, plus deux actions au survol.
|
||
Elles restent visibles au clavier (:focus-within) pour rester atteignables
|
||
sans souris. */
|
||
.conv-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
||
.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1}
|
||
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer}
|
||
.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||
/* Séparateur de groupe dans la barre latérale : sépare les réglages d'IA des
|
||
réglages d'application, sans imbriquer un second niveau de repli (illisible
|
||
dans une colonne aussi étroite). */
|
||
.side-sep{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);
|
||
margin:18px 0 8px;padding-top:12px;border-top:1px solid var(--border)}
|
||
/* Choix d'avatar : une grille de pastilles emoji, celle retenue encadrée. */
|
||
.avpick{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
|
||
.avpick button{font-size:15px;line-height:1;padding:5px 6px;background:var(--panel);
|
||
border:1px solid var(--border);border-radius:8px;cursor:pointer}
|
||
.avpick button:hover{border-color:var(--accent)}
|
||
.avpick button.sel{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 15%,transparent)}
|
||
/* Avatar + nom en tête de bulle : l'emoji garde sa taille propre, le nom hérite
|
||
du style de .label (petites capitales) mais sans forcer la casse du prénom. */
|
||
.msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px}
|
||
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
|
||
/* Captures d'écran et images de l'agent : bornées à la largeur de la bulle, et
|
||
cliquables pour ouvrir en grand dans un onglet. */
|
||
.msg .body img{max-width:100%;height:auto;border-radius:8px;border:1px solid var(--border);display:block;margin:8px 0}
|
||
/* pastille d'état service, en haut à droite de la barre latérale */
|
||
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
||
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||
.statuspill.err{color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)}
|
||
.statuspill.loading{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)}
|
||
.statuspill.loading .dot{animation:pulse 1s ease-in-out infinite}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
||
.statuspill .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
|
||
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
||
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
|
||
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
|
||
.addbtn{font-size:11px;padding:4px 11px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:999px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
||
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
|
||
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
|
||
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
|
||
(le trait est réservé aux sections principales — cf. `details`). La séparation
|
||
visuelle vient de la carte de lignes qui suit (.slist). */
|
||
/* Barre « Voir la mémoire » : libellé à gauche, bouton « + ajouter » à droite.
|
||
Pas de chevron : la liste s'ouvre sous la ligne, ça se voit. */
|
||
.pages-bar{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;padding:7px 0;margin-top:4px}
|
||
.pages-bar .pages-title{font-size:11.5px;color:var(--dim);font-weight:600}
|
||
.pages-bar .addbtn{margin-left:auto}
|
||
.mem-search{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius: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-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)}
|
||
.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 « Loki »). L'état agent reste signalé par sa
|
||
pastille dans les réglages. */
|
||
#brand.agent #brand-a{color:var(--accent)}
|
||
/* interrupteur (switch) */
|
||
/* Sous-groupe du mode agent (Mémoire, Accès internet, Serveurs MCP, Paramètres).
|
||
MÊME grammaire que « Machine » ou « Apparence » : étiquette de groupe NUE
|
||
au-dessus, puis UNE carte .slist qui porte toutes les lignes. Auparavant
|
||
chaque sous-groupe était une carte qui embarquait son propre titre et du
|
||
contenu libre aux marges variables — quatre îlots décollés au lieu d'une
|
||
colonne régulière. Le grisé « agent désactivé » se pose sur le GROUPE
|
||
(étiquette comprise), plus sur la carte seule. */
|
||
.gated-group{margin:0 0 10px;transition:opacity .15s}
|
||
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
|
||
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
|
||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
|
||
détache pas du rail en mode clair. Même teinte que .pe-sl. */
|
||
.switch .slider{position:absolute;inset:0;background:color-mix(in srgb,var(--dim) 55%,transparent);border-radius:999px;transition:background .25s ease}
|
||
.switch .slider::before{content:"";position:absolute;width:14px;height:14px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .25s ease;box-shadow:0 1px 2px rgba(0,0,0,.25)}
|
||
.switch input:checked + .slider{background:var(--accent)}
|
||
.switch input:checked + .slider::before{transform:translateX(16px);background:var(--bg)}
|
||
.switch input:focus-visible + .slider{box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--accent)}
|
||
h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600}
|
||
details>summary::-webkit-details-marker{display:none}
|
||
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius: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}
|
||
/* Liste de lignes cliquables (presets, pages mémoire) posée à fleur de carte :
|
||
une pile de petites cartes bordées faisait « trop de carrés » là où le reste
|
||
du menu aligne des lignes séparées par un filet. L'actif se signale par un
|
||
voile et un liseré à gauche, plus par un cadre propre. */
|
||
.rowlist>.preset{border:0;border-radius:0;margin:0;padding:6px 10px;background:transparent}
|
||
.rowlist>.preset:not(:last-child){border-bottom:1px solid var(--border)}
|
||
.rowlist>.preset:hover{background:var(--row-bg)}
|
||
.rowlist>.preset.active{position:relative;background:var(--row-bg-on);color:var(--text);font-weight:600}
|
||
/* La carte (.slist) laisse volontairement déborder son contenu (menus), elle ne
|
||
rogne donc pas ses coins : c'est la LIGNE de bord qui porte l'arrondi, sinon
|
||
le voile dépasse l'angle de la carte. */
|
||
.rowlist:first-child>.preset:first-child{border-radius:8px 8px 0 0}
|
||
.rowlist:last-child>.preset:last-child{border-radius:0 0 8px 8px}
|
||
.rowlist:first-child:last-child>.preset:only-child{border-radius:8px}
|
||
/* Liseré de gauche : un trait DROIT (pas un arc) qui court sur TOUTE la hauteur
|
||
de la ligne. Donc pas de box-shadow interne (l'arrondi le courberait) mais un
|
||
élément à part, aux bords francs, que l'arrondi de la ligne de bord se
|
||
contente de ROGNER — il se perd dans l'angle au lieu de le suivre. */
|
||
.rowlist>.preset{overflow:hidden}
|
||
.rowlist>.preset.active::before,.rowlist>.preset.pending::before{content:"";position:absolute;
|
||
left:0;top:0;bottom:0;width:3px;background:var(--text)}
|
||
/* La liste est REDESSINÉE à chaque rafraîchissement : une transition ne jouerait
|
||
pas (l'élément vient de naître), d'où des @keyframes d'entrée. Le liseré entre
|
||
en glissant depuis la gauche, le voile et la puce arrivent en fondu.
|
||
⚠️ Elles ne sont posées QUE via .sel-anim, classe mise par loadPresets le seul
|
||
redessin où la sélection a bougé : sans ça elles repartaient à chaque
|
||
rafraîchissement et la puce clignotait en continu. */
|
||
@keyframes bar-in{from{transform:translateX(-100%)}to{transform:translateX(0)}}
|
||
@keyframes row-in{from{background:transparent}}
|
||
@keyframes dot-in{from{opacity:0;transform:scale(.3)}to{opacity:1;transform:scale(1)}}
|
||
.sel-anim.active::before,.sel-anim.pending::before{animation:bar-in .26s cubic-bezier(.2,.7,.3,1) both}
|
||
.rowlist>.preset.sel-anim.active,.rowlist>.preset.sel-anim.pending{animation:row-in .26s ease-out both}
|
||
.sel-anim .preset-dot{animation:dot-in .26s cubic-bezier(.2,1.5,.5,1) both}
|
||
/* Bascule en cours : la ligne visée s'allume DÉJÀ (le clic doit se voir tout de
|
||
suite), mais sa barre reste EN RETRAIT — même blanc, opacité plus basse, et
|
||
elle respire. Le service met plusieurs secondes à redémarrer : la barre pleine
|
||
(plus la puce) est réservée au preset vraiment actif. */
|
||
.rowlist>.preset.pending{position:relative;background:var(--row-bg);color:var(--text);font-weight:600}
|
||
.rowlist>.preset.pending::before{animation:bar-breathe 1.5s ease-in-out infinite}
|
||
.rowlist>.preset.sel-anim.pending::before{
|
||
animation:bar-in .26s cubic-bezier(.2,.7,.3,1) both,bar-breathe 1.5s ease-in-out .26s infinite}
|
||
@keyframes bar-breathe{0%,100%{opacity:.5}50%{opacity:.2}}
|
||
/* Ligne à deux étages (titre + étiquettes) : l'interligne interne suffit, on
|
||
resserre donc le haut et le bas. */
|
||
.rowlist>.preset .preset-meta{margin-top:4px}
|
||
/* Messages et « voir plus » de la liste : alignés sur le retrait des lignes. */
|
||
.rowlist>.muted,.rowlist>.mem-more{padding:9px 10px}
|
||
.preset-info{flex:1;min-width:0}
|
||
.preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word}
|
||
/* Puce du preset actif : disque dessiné en CSS (le caractère « ● » dépend de la
|
||
police du système — plus petit et plus bas sous Windows que sous macOS). */
|
||
.preset-dot{display:inline-block;width:7px;height:7px;border-radius:50%;
|
||
background:currentColor;margin:0 7px 0 0;flex-shrink:0;
|
||
/* ⚠️ PAS `vertical-align:middle` : ce mot-clé se cale sur la HAUTEUR D'X de la
|
||
police, qui n'est pas la même d'un système à l'autre (Segoe UI sous Windows
|
||
vs SF sous macOS) — la puce tombait trop bas sur PC. Un décalage EN LONGUEUR
|
||
part de la ligne de base et ne dépend que de la taille de texte : même
|
||
rendu partout. .34em ≈ milieu des capitales, moins la moitié du disque. */
|
||
vertical-align:calc(.34em - 3.5px)}
|
||
/* Icône d'engrenage : centrée verticalement sur la ligne (sa silhouette ronde se
|
||
lit bien au milieu de son coin, là où le crayon penché tirait de travers). */
|
||
.preset-edit{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;
|
||
margin:0 -2px 0 0;padding:4px;line-height:0;background:transparent;border:none;opacity:.55}
|
||
.preset-edit:hover{opacity:1;color:var(--accent);background:transparent}
|
||
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
|
||
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius: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}
|
||
/* Accueil du fil vide. Posé en surimpression du chat (et non DANS le fil) pour ne
|
||
pas perturber le défilement ni le rendu du replay ; pointer-events:none pour
|
||
qu'il ne vole jamais un clic à la zone de saisie. Il vit sous le voile de
|
||
chargement (z-index plus bas) : jamais les deux visibles en même temps. */
|
||
#chat-empty{position:absolute;inset:0 0 150px;z-index:3;display:none;flex-direction:column;
|
||
align-items:center;justify-content:center;gap:10px;pointer-events:none;
|
||
opacity:0;transition:opacity .35s ease}
|
||
#chat-empty.show{display:flex;opacity:1}
|
||
/* Le clone perd les règles d'id de #brand (les id sont retirés), dont l'espace
|
||
entre le « A » et le mot : on le repose ici, à l'échelle du logo agrandi
|
||
(2 px pour 10 px de haut → 4 px pour 20 px), sinon le A s'encastre dans le J. */
|
||
#ce-logo{display:inline-flex;align-items:center;gap:4px;color:var(--text)}
|
||
#ce-logo svg{height:20px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
|
||
#ce-logo .ce-word{display:inline-flex;color:var(--accent)}
|
||
#ce-title{font-size:14px;color:var(--dim);letter-spacing:.03em}
|
||
.cl-dots{display:inline-flex;gap:5px;align-items:center}
|
||
.cl-dots span{width:7px;height:7px;border-radius:50%;background:var(--dim);animation:typingdot 1.2s infinite ease-in-out}
|
||
.cl-dots span:nth-child(2){animation-delay:.16s}
|
||
.cl-dots span:nth-child(3){animation-delay:.32s}
|
||
.msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block}
|
||
/* Bulles repliables (reasoning / tool) : une fois la réponse arrivée, on les
|
||
replie en douceur pour désencombrer le fil, tout en gardant le label (et son
|
||
compteur de tokens) cliquable pour redéplier. */
|
||
.msg.collapsible .label{cursor:pointer;user-select:none;margin-bottom:0}
|
||
/* ===== Barre latérale ==============================================
|
||
Colonne de réglages dense : 12 sections repliées doivent tenir sans faire
|
||
défiler. Un seul gabarit, repris des listes de la modale de preset.
|
||
Hauteurs : en-tête de section 34 px, ligne de réglage 34 px. */
|
||
.side{padding:10px 12px}
|
||
/* ⚠️ Le raccourci `padding` ci-dessus écrase les esquives d'encoche posées plus
|
||
haut (règles tiroir). Quand la barre est un TIROIR (téléphone, ou mode
|
||
« barre en tiroir » sur ordinateur), elle est en position:fixed : le padding
|
||
du <body> ne la protège pas, il faut donc reposer les env() ici — sinon
|
||
« Loki » et la pastille d'état passent sous l'encoche en PWA iOS. */
|
||
@media (max-width:720px){
|
||
.side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
|
||
}
|
||
[data-hide-side="1"] .side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
|
||
.sidehead{margin-bottom:10px}
|
||
|
||
/* --- En-tête de section --- */
|
||
details{margin:0;border-top:1px solid var(--border)}
|
||
details:first-of-type{border-top:0}
|
||
details[open]{padding-bottom:10px}
|
||
details>summary{display:flex;align-items:center;gap:8px;height:34px;font-size:13px;font-weight:500;
|
||
color:var(--text);cursor:pointer;list-style:none;user-select:none}
|
||
.stitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
/* Tous les titres du menu (sections ET étiquettes de groupe) s'écrivent de la
|
||
MÊME façon : monospace, capitales, même interlettrage. La casse vient du CSS,
|
||
pas du HTML, pour qu'aucun libellé ne puisse diverger. Seule la taille et la
|
||
couleur distinguent les deux niveaux. */
|
||
.stitle,.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{
|
||
font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em}
|
||
/* Pastille d'état d'une section (mode agent, accès internet, MCP, accès distant).
|
||
Un point + un mot, dans une pilule — l'ancien couple « on / off » en étiquette
|
||
carrée se lisait mal et jurait avec le reste du menu. Actif = pilule pleine
|
||
(inversée), inactif = contour discret. */
|
||
.sbadge{flex:none;display:inline-flex;align-items:center;gap:5px;
|
||
font-family:var(--font);font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;
|
||
padding:2px 8px;border-radius:999px;border:1px solid var(--border);color:var(--dim);background:transparent;white-space:nowrap}
|
||
.sbadge::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;opacity:.55}
|
||
.sbadge.on{background:var(--text);border-color:var(--text);color:var(--bg)}
|
||
.sbadge.on::before{opacity:1}
|
||
.sbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}
|
||
.sbadge.err{color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent)}
|
||
.sbadge:empty{display:none}
|
||
/* Dans une étiquette de groupe, la pastille se plaque à DROITE (comme dans les
|
||
titres de section, où .stitle occupe la place) au lieu de coller au libellé.
|
||
L'ordre visuel reste : libellé · « ? » · … · pastille. */
|
||
.subhead .sbadge{margin-left:auto;order:2}
|
||
.subhead .help{order:1}
|
||
details>summary::-webkit-details-marker{display:none}
|
||
details>summary:hover{color:var(--dim)}
|
||
/* Chevron : un carré dont on n'affiche que deux côtés. Il pivote AUTOUR DE SON
|
||
CENTRE (aucune translation), sinon il se décale en tournant. */
|
||
details>summary::after{content:"";flex:none;width:6px;height:6px;
|
||
border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);
|
||
transform:rotate(-45deg);transition:transform .18s ease}
|
||
details[open]>summary::after{transform:rotate(45deg)}
|
||
|
||
/* --- Étiquette de groupe : une seule règle pour toutes les variantes --- */
|
||
.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{position:relative;display:flex;align-items:center;
|
||
font-size:11px;font-weight:600;color:var(--dim);margin:10px 2px 5px}
|
||
.io-cap{margin:0 2px 5px}
|
||
/* ⚠️ APRÈS la règle commune ci-dessus, qui pose une marge haute/basse : dans une
|
||
LIGNE de carte, elle décalait le libellé vers le bas au lieu de le centrer. */
|
||
.pages-bar .pages-title{margin:0}
|
||
/* Étiquette de groupe en tête d'un sous-groupe du mode agent : elle suit une
|
||
carte, elle a donc besoin de son air normal — mais pas d'un cumul de marges. */
|
||
.gated-group>.subhead:first-child{margin-top:12px}
|
||
/* Une étiquette de groupe qui suit immédiatement le titre de section n'a pas
|
||
besoin de reprendre de l'air : le titre en a déjà donné. */
|
||
details>summary+.subhead{margin-top:4px}
|
||
.io-group{margin-top:8px}
|
||
|
||
/* --- Carte de liste --- */
|
||
/* overflow VISIBLE : les infobulles (« ? ») sont positionnées en absolu par
|
||
rapport à la ligne, un débordement caché les TRONQUERAIT — elles doivent
|
||
pouvoir déborder de la carte. Les lignes n'ont pas de fond propre, l'arrondi
|
||
n'a donc rien à rogner. */
|
||
.slist{background:var(--panel);border:1px solid var(--border);border-radius:9px;
|
||
overflow:visible;margin:0 0 8px}
|
||
.slist:last-child{margin-bottom:0}
|
||
/* Contenu libre (champ + boutons) : le rembourrage passe sur la carte. */
|
||
.slist.pad{padding:8px 10px}
|
||
.slist.pad>:first-child{margin-top:0}
|
||
.slist.pad>:last-child{margin-bottom:0}
|
||
/* Contenu libre posé DANS une carte de lignes (champs Crawl4AI, liste MCP,
|
||
pages mémoire) : même rembourrage et même filet de séparation qu'une ligne,
|
||
pour qu'il ne flotte pas au milieu de la carte. */
|
||
.slist>.rowbody{padding:9px 10px}
|
||
.slist>.rowbody:not(:first-child),.slist>.pages-bar:not(:first-child){border-top:1px solid var(--border)}
|
||
/* …sauf juste après une ligne de réglage : elle porte DÉJÀ son filet bas, les
|
||
deux se cumuleraient en un trait double. */
|
||
.slist>.switchrow+.rowbody,.slist>.switchrow+.pages-bar{border-top:0}
|
||
.slist>.rowbody>:first-child{margin-top:0}
|
||
.slist>.rowbody>:last-child{margin-bottom:0}
|
||
.slist>.pages-bar{min-height:34px;padding:4px 10px;margin-top:0}
|
||
/* Contrôle posé à droite d'une ligne (liste « mode » de la mémoire) : même place
|
||
que l'interrupteur, largeur ajustée au contenu. */
|
||
.switchrow>.sctl{margin-left:auto;order:2;width:auto;max-width:62%}
|
||
|
||
/* --- Ligne de réglage : hauteur constante, contrôle à droite --- */
|
||
.switchrow{position:relative;display:flex;align-items:center;gap:10px;min-height:34px;padding:6px 0;
|
||
font-size:12.5px;cursor:pointer;user-select:none}
|
||
.slist>.switchrow{padding:6px 10px}
|
||
.slist>.switchrow:not(:last-child){border-bottom:1px solid var(--border)}
|
||
.switchrow .switch{margin-left:auto;order:2;flex:none}
|
||
.switchrow>span:first-child{min-width:0}
|
||
|
||
/* --- Bloc de statistique (GPU, RAM) --- */
|
||
.stat{padding:9px 10px;display:flex;flex-direction:column;gap:6px}
|
||
.stat:not(:last-child){border-bottom:1px solid var(--border)}
|
||
/* La RAM vit dans son propre conteneur (masqué quand l'info manque) : le filet
|
||
de séparation se pose donc sur le conteneur, pas entre deux .stat frères. */
|
||
#ram-details{border-top:1px solid var(--border)}
|
||
.stat-h{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
|
||
.stat-n{font-size:12.5px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.stat-v{flex:none;font-size:12.5px;color:var(--text);font-variant-numeric:tabular-nums}
|
||
.stat-s{font-size:11px;color:var(--dim)}
|
||
|
||
/* --- Lignes clé / valeur (config active) --- */
|
||
.kv{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:34px;
|
||
padding:6px 10px;border-bottom:1px solid var(--border);font-size:12.5px}
|
||
.kv:last-child{border-bottom:0}
|
||
.kv span:first-child{color:var(--text);flex-shrink:0}
|
||
|
||
/* --- Champs et boutons --- */
|
||
.sctl,.io-input{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);
|
||
border-radius: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):not(.iconbtn){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}
|
||
/* Bouton d'action à côté d'un champ (copier, ouvrir) : un CARRÉ à la hauteur du
|
||
champ, icône au trait centrée. Le glyphe seul (⧉, ↗) dans un bouton texte se
|
||
posait de travers et changeait de largeur d'une police à l'autre. */
|
||
.iconbtn{flex:none;display:inline-flex;align-items:center;justify-content:center;
|
||
width:34px;height:34px;padding:0;margin:0;color:var(--dim);background:var(--panel);
|
||
border:1px solid var(--border);border-radius:9px;cursor:pointer}
|
||
.iconbtn:hover{color:var(--text);border-color:var(--dim);background:var(--accent-bg)}
|
||
.iconbtn svg{width:15px;height:15px;display:block}
|
||
.side .btn-danger-o{border-color:var(--err);color:var(--err)}
|
||
.side .btn-danger-o:hover{background:var(--danger-bg);border-color:var(--err);color:var(--err)}
|
||
.side .muted{font-size:11.5px;line-height:1.5}
|
||
|
||
/* ===== Réglages d'affichage ======================================
|
||
Chaque option est un interrupteur indépendant (Apparence), porté par un
|
||
attribut sur <html> et persisté dans localStorage + /api/prefs :
|
||
data-hide-reasoning : masque les bulles de raisonnement
|
||
data-hide-tools : masque les bulles d'appel d'outil
|
||
data-fold-tools : les garde repliées (dé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"] #lc-chip:not([hidden]){display:block}
|
||
[data-hide-side="1"] #backdrop.open{display:block}
|
||
[data-hide-side="1"] body.drawer-open #menubtn,[data-hide-side="1"] body.drawer-open #lc-chip{display:none}
|
||
[data-hide-side="1"] .main{width:100%}
|
||
/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui
|
||
peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli.
|
||
width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0
|
||
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-compact{display:none;margin:0;padding:2px 10px;font-size:10px;background:var(--warn-bg);border-color:var(--warn,#c93)}
|
||
#inputbar{padding:8px 12px;display:flex;gap:8px}
|
||
/* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir
|
||
changer ses marges, ce qu'un style en ligne empêchait). */
|
||
#sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7}
|
||
/* Moteur pas encore prêt : l'aide clavier cède la place à l'état du chargement. */
|
||
#sendhint.waiting{opacity:1;color:var(--dim)}
|
||
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius: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}
|
||
h1{font-size:15px}
|
||
h2,details>summary{font-size:13px}
|
||
#menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))}
|
||
#lc-chip{height:32px;font-size:11px;left:calc(46px + env(safe-area-inset-left));top:calc(6px + env(safe-area-inset-top))}
|
||
}
|
||
/* --- Backend llama.cpp (2 modes : rapide / optimisée) --------------------- */
|
||
.lc-head{font-size:12px;line-height:1.5}
|
||
/* Les trois modes sont des LIGNES de la carte d'installation (elles étaient des
|
||
cartes dans une carte) : pas de cadre propre, un filet entre elles, comme
|
||
n'importe quelle ligne de réglage du menu. */
|
||
.lc-modes{display:flex;flex-direction:column}
|
||
.lc-modes.busy{opacity:.5;pointer-events:none}
|
||
.slist>.lc-modes:not(:first-child){border-top:1px solid var(--border)}
|
||
.lc-mode{padding:10px;cursor:pointer;transition:background .12s}
|
||
.lc-mode:not(:last-child){border-bottom:1px solid var(--border)}
|
||
.lc-mode:hover{background:var(--accent-bg)}
|
||
.lc-mode-t{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px}
|
||
/* « conseillé » : pilule PLEINE (texte inversé). En contour, elle héritait du
|
||
gris atténué du thème monochrome et devenait illisible en mode sombre. */
|
||
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:8px;padding:1px 6px}
|
||
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
|
||
/* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle
|
||
les trois cartes grandissaient d'un cran une fois l'état connu. */
|
||
.lc-mode-state{font-size:11px;min-height:16px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||
.lc-mode-go{color:var(--accent)}
|
||
.lc-mode-active-tag{color:var(--accent);font-weight:600}
|
||
.lc-update-link{color:var(--dim);text-decoration:none;cursor:pointer}
|
||
.lc-update-link:hover{color:var(--accent)}
|
||
/* Choix du moteur dans l'éditeur de modèle (3 options : rapide/optimisé/perso) */
|
||
.be-opts{display:flex;flex-direction:column;gap:7px;margin:6px 0 8px}
|
||
.be-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer}
|
||
.be-opt input{margin:0;flex-shrink:0}
|
||
.be-note{font-size:11px;color:var(--dim);margin-left:auto}
|
||
|
||
/* ===== Éditeur de preset — listes groupées façon Réglages iOS ======== */
|
||
#modal .modal-card{max-width:520px;overflow:hidden}
|
||
.pe-body{padding:18px 16px 24px;display:flex;flex-direction:column;gap:26px;overflow-y:auto;overflow-x:hidden;flex:1;background:var(--bg)}
|
||
/* Nombres : pas de flèches natives (spinner), champ épuré. */
|
||
.pe-val[type=number]{-moz-appearance:textfield}
|
||
.pe-val::-webkit-outer-spin-button,.pe-val::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
|
||
/* Un groupe = un petit titre + une liste encadrée aux coins arrondis. */
|
||
.pe-group{display:flex;flex-direction:column;gap:8px}
|
||
/* `display:flex` sur la classe l'emporte sur la règle par défaut du navigateur
|
||
pour [hidden] : sans cette ligne, masquer un groupe via l'attribut hidden
|
||
n'avait AUCUN effet visible (section « Cartes graphiques » qui restait
|
||
affichée avec une seule carte). */
|
||
.pe-group[hidden]{display:none}
|
||
.pe-gh{font-size:12px;font-weight:600;color:var(--dim);padding:0 6px}
|
||
.pe-list{background:var(--panel);border:1px solid var(--border);border-radius: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}
|
||
/* Séparateur porté par la ligne SUIVANTE (border-top), pas par la précédente
|
||
(border-bottom + :not(:last-child)) : `:last-child` compte aussi les lignes
|
||
masquées, donc une ligne repliée gardait sa bordure basse, qui doublait
|
||
celle de la carte. Avec border-top, la bordure est portée par l'élément
|
||
caché lui-même — invisible tant qu'il l'est. */
|
||
.pe-row + .pe-row{border-top:1px solid var(--border)}
|
||
.pe-row-l{font-size:14px;color:var(--text);flex:1;min-width:0;line-height:1.3}
|
||
.pe-row-l .pe-sub{display:block;font-size:11px;color:var(--dim);margin-top:2px}
|
||
.pe-row-c{flex:none;display:flex;align-items:center;gap:6px;max-width:62%;justify-content:flex-end}
|
||
/* Valeur éditable sans bordure, alignée à droite (nombre / texte). */
|
||
.pe-val{border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;padding:4px 0;width:110px;max-width:44vw}
|
||
.pe-val::placeholder{color:var(--dim)}
|
||
.pe-val:focus{outline:none}
|
||
.pe-unit{font-size:12px;color:var(--dim)}
|
||
/* Select sans bordure, avec chevron discret. */
|
||
.pe-selc{position:relative;display:flex;align-items:center;max-width:100%}
|
||
.pe-selc select{appearance:none;-webkit-appearance:none;border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;text-align-last:right;padding:4px 16px 4px 0;cursor:pointer;max-width:100%;text-overflow:ellipsis}
|
||
.pe-selc select:focus{outline:none}
|
||
.pe-selc::after{content:"›";position:absolute;right:0;transform:rotate(90deg);color:var(--dim);font-size:14px;pointer-events:none}
|
||
/* Variante pleine largeur : la liste native s'ouvre alignée à gauche de la
|
||
ligne (et non collée au bord droit) et le nom de fichier a la place de
|
||
s'afficher en entier. */
|
||
.pe-selc.wide{width:100%}
|
||
.pe-selc.wide select{flex:1;text-align:left;text-align-last:left;padding-right:18px}
|
||
.pe-selc.wide::after{right:2px}
|
||
/* Ligne « champ pleine largeur » (Nom, chemin, config brute…). */
|
||
.pe-row.stack{flex-direction:column;align-items:stretch;gap:8px}
|
||
/* Répartition entre cartes graphiques : UNE barre, sur laquelle on glisse
|
||
directement. Le <input type=range> est posé par-dessus, piste transparente :
|
||
sinon on voyait deux barres superposées (la barre de proportion + la piste
|
||
blanche native du navigateur). Deux teintes du MÊME accent, pas deux
|
||
couleurs — la barre exprime une proportion, pas deux catégories. */
|
||
.gpu-split{position:relative;display:flex;align-items:center;height:22px}
|
||
.gpu-bar{display:flex;width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)}
|
||
.gpu-bar>span{background:var(--accent);flex:none;min-width:0;transition:width .12s}
|
||
.gpu-bar>span.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)}
|
||
.gpu-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize}
|
||
.gpu-range:focus{outline:none}
|
||
.gpu-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none}
|
||
.gpu-range::-moz-range-track{height:22px;background:transparent;border:none}
|
||
.gpu-range::-moz-range-progress{background:transparent}
|
||
.gpu-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize}
|
||
.gpu-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize}
|
||
.gpu-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
|
||
.gpu-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
|
||
.gpu-legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:11.5px;color:var(--dim)}
|
||
.gpu-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:5px}
|
||
.gpu-legend i.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)}
|
||
.gpu-shares{display:flex;flex-wrap:wrap;gap:10px}
|
||
.gpu-share{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--dim)}
|
||
.gpu-share input{width:62px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:5px 7px;font:inherit;text-align:right}
|
||
.gpu-share input:focus{outline:none;border-color:var(--accent)}
|
||
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius: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:9px 12px;border-top:1px solid var(--border);display:flex;justify-content:space-between;
|
||
align-items:center;gap:8px;flex-wrap:nowrap}
|
||
.pe-foot .foot-l{display:flex;align-items:center;gap:8px;min-width:0}
|
||
.pe-foot .foot-r{display:flex;gap:8px;flex:none}
|
||
.pe-btn{margin:0;padding:7px 15px;font-size:12.5px;border-radius:8px;font-weight:600;cursor:pointer;
|
||
border:1px solid var(--border);background:var(--panel);color:var(--text);white-space:nowrap}
|
||
.pe-btn:hover{border-color:var(--dim)}
|
||
.pe-btn.primary{border-color:var(--accent);background:var(--accent);color:var(--bg)}
|
||
.pe-btn.primary:hover{filter:brightness(1.08)}
|
||
/* Suppression : icône seule, en retrait. Un bouton plein « supprimer » pesait
|
||
autant que « enregistrer » alors que c'est l'action rare — et à trois boutons
|
||
larges, le pied passait à deux lignes sur téléphone. */
|
||
.pe-del{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;
|
||
margin:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--err);cursor:pointer}
|
||
.pe-del svg{width:15px;height:15px;display:block}
|
||
.pe-del:hover{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 40%,transparent)}
|
||
/* Option d'une confirmation : une PASTILLE à cocher, pas une case système suivie
|
||
d'un bout de texte. Éteinte : contour discret. Allumée : elle passe au rouge,
|
||
comme l'action destructrice qu'elle étend. Vit dans la boîte de confirmation
|
||
(#ask-check), plus dans le pied de l'éditeur. */
|
||
.pe-delmodel{align-self:flex-start;margin-top:14px;display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;
|
||
letter-spacing:.02em;color:var(--dim);cursor:pointer;white-space:nowrap;
|
||
padding:5px 11px;border:1px solid var(--border);border-radius:999px;background:var(--panel);
|
||
transition:color .12s,border-color .12s,background .12s}
|
||
.pe-delmodel input{position:absolute;opacity:0;width:0;height:0}
|
||
/* Le point tient lieu de case à cocher : creux au repos, plein une fois coché. */
|
||
.pe-delmodel::before{content:"";width:6px;height:6px;border-radius:50%;flex:none;
|
||
border:1px solid currentColor;background:transparent}
|
||
.pe-delmodel:hover{color:var(--text);border-color:var(--dim)}
|
||
.pe-delmodel:has(input:checked){color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent);background:var(--danger-bg)}
|
||
.pe-delmodel:has(input:checked)::before{background:currentColor}
|
||
/* Éditeur de preset/mémoire — mise en forme propre du modale */
|
||
.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px}
|
||
/* Ouverture/fermeture animées. L'état par défaut est l'état FERMÉ ; la classe
|
||
.show (posée par showModal/hideModal) fait jouer la transition dans les deux
|
||
sens — une @keyframes n'aurait animé que l'ouverture. */
|
||
.modal-ov,.ask-overlay{opacity:0;transition:opacity .16s ease}
|
||
.modal-ov.show,.ask-overlay.show{opacity:1}
|
||
.modal-card,.ask-box{transform:translateY(10px) scale(.985);opacity:0;
|
||
transition:transform .18s cubic-bezier(.2,.8,.3,1),opacity .18s ease}
|
||
.modal-ov.show .modal-card,.ask-overlay.show .ask-box{transform:none;opacity:1}
|
||
@media (prefers-reduced-motion:reduce){
|
||
.modal-ov,.ask-overlay,.modal-card,.ask-box{transition:none}
|
||
}
|
||
.modal-card{position:relative;background:var(--bg);border:1px solid var(--border);border-radius: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}
|
||
/* Croix de fermeture : un CARRÉ, pas un rectangle. Avec un padding asymétrique
|
||
(3px vertical / 10px horizontal) le glyphe paraissait écrasé et la cible de
|
||
clic était basse. 32px, glyphe centré, taille de cible correcte au doigt. */
|
||
.modal-x{margin:0;padding:0;width:32px;height:32px;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:19px;line-height:1;color:var(--dim)}
|
||
.modal-body{padding:16px;overflow:auto;flex:1}
|
||
/* Modale ouverte mais pas encore remplie : on affiche le voile de chargement À LA
|
||
PLACE du formulaire et du pied. Estomper le formulaire ne suffisait pas — on
|
||
voyait quand même les champs s'écrire un par un derrière le voile. */
|
||
/* Voile OPAQUE, posé sous l'en-tête (le titre reste lisible) : `visibility:hidden`
|
||
sur le formulaire suffirait en théorie, mais un fond plein garantit qu'aucun
|
||
reste du preset précédent (moteur coché, réglages) ne puisse apparaître une
|
||
fraction de seconde. --mh = hauteur mesurée de l'en-tête, posée à l'ouverture. */
|
||
/* ⚠️ Aucune transition tant que le formulaire est caché, NI pendant la frame où
|
||
il réapparaît : WebKit diffère les transitions des éléments non rendus et les
|
||
joue à leur retour à l'écran. Le sélecteur de moteur glissait donc de l'ancien
|
||
preset au nouveau SOUS LES YEUX, une fois le chargement terminé. */
|
||
.modal-ov.loading .pe-body *,.modal-ov.no-anim .pe-body *{transition:none!important}
|
||
.modal-load{display:none;position:absolute;inset:var(--mh,52px) 0 0 0;z-index:2;
|
||
align-items:center;justify-content:center;background:var(--bg);border-radius:0 0 12px 12px}
|
||
.modal-ov.loading .modal-load{display:flex}
|
||
/* visibility (et non display) : le formulaire garde sa place, donc la carte a
|
||
déjà sa taille finale — la révéler ne provoque plus d'à-coup de hauteur. */
|
||
.modal-ov.loading .modal-body,
|
||
.modal-ov.loading .pe-body,
|
||
.modal-ov.loading .pe-foot,
|
||
.modal-ov.loading .modal-foot{visibility:hidden}
|
||
/* Rond qui tourne — les trois points sont la signature du CHAT, pas des modales. */
|
||
.spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--border);
|
||
border-top-color:var(--dim);animation:spin .7s linear infinite}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
@media (prefers-reduced-motion:reduce){.spinner{animation-duration:2s}}
|
||
.modal-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.foot-l{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||
.foot-r{display:flex;gap:8px}
|
||
.mform{display:flex;flex-direction:column;gap:14px}
|
||
.fld{display:flex;flex-direction:column;gap:5px}
|
||
.fld-l{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)}
|
||
.fld-l .opt{text-transform:none;letter-spacing:0;font-weight:400;opacity:.65}
|
||
.fld-hint{font-size:11px;color:var(--dim);margin-top:2px}
|
||
.ctl{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius: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:2px}
|
||
.lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block}
|
||
.lc-logbar.open #lc-log-caret{transform:rotate(90deg)}
|
||
#lc-log{background:var(--panel);border:1px solid var(--border);border-radius: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}
|
||
|
||
/* Modale MCP (ajout/édition) : les champs des groupes stdio/http n'avaient aucun
|
||
espacement vertical (le conteneur n'est pas un flex à gap) → ils se touchaient.
|
||
On aligne leur rythme sur celui du reste du formulaire (.mform). */
|
||
#mcp-stdio-fields,#mcp-http-fields{display:flex;flex-direction:column;gap:14px}
|
||
|
||
/* ===== Mise en page de la conversation =============================
|
||
Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond),
|
||
question de l'utilisateur en pastille arrondie à droite, composer en
|
||
« pilule », pas d'étiquettes VOUS/Loki. Ces règles surchargent la mise en
|
||
page générique plus haut : elles doivent donc rester APRÈS elle. */
|
||
body{font-size:15px}
|
||
h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
|
||
/* Barre latérale : plus sombre/atténuée que la zone de conversation. */
|
||
.side{background:var(--panel);border-right-color:transparent}
|
||
@media (max-width:720px){.side{background:var(--panel)}}
|
||
/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile.
|
||
⚠️ JAMAIS le raccourci `padding` ici : le rembourrage BAS réserve la hauteur
|
||
de la carte de saisie (voir #chat plus bas) et il est réglé ailleurs. Une
|
||
règle en raccourci le remet silencieusement à sa valeur du raccourci — c'est
|
||
exactement ce qui, jusqu'à ce correctif, laissait la fin du fil coincée sous
|
||
la carte au-delà de 721 px de large. Longhands uniquement. */
|
||
#chat{padding-top:28px;padding-inline:max(16px,calc(50% - 380px));gap:22px;scrollbar-gutter:stable both-edges}
|
||
/* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */
|
||
#chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7}
|
||
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
|
||
#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
|
||
#chat .msg.user .label{color:var(--dim)}
|
||
/* Libellés : l'amont les masquait tous ici, les mots « user » / « assistant »
|
||
n'ajoutant rien à l'alignement. Ils portent désormais un avatar et un prénom
|
||
(voir 17-identity.js), qui eux identifient vraiment l'interlocuteur — on les
|
||
réaffiche donc, en ligne et discrets. Restent masqués les libellés SANS
|
||
avatar, c'est-à-dire les bulles techniques non repliables. */
|
||
#chat .msg .label{display:none}
|
||
#chat .msg.collapsible .label{display:block}
|
||
#chat .msg.user .label,#chat .msg.assistant .label{display:flex;align-items:center;gap:0;opacity:.85}
|
||
/* Côté question, le libellé s'aligne à droite comme la bulle. */
|
||
#chat .msg.user .label{justify-content:flex-end}
|
||
/* Mesures de génération sous la réponse (prefill / decode). Assez discrètes pour
|
||
ne pas concurrencer le texte, assez lisibles pour être utiles. Escamotables
|
||
depuis Apparence → Affichage. */
|
||
#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em;
|
||
color:var(--dim);opacity:.75}
|
||
#chat .msg .statline:empty{display:none}
|
||
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. */
|
||
/* --fade = la bande transparente au-dessus de la carte, celle où le fil doit
|
||
s'effacer. Le dégradé s'exprime dans la MÊME unité (et non en % de la hauteur
|
||
totale du composeur, qui varie) : l'opacité est donc pleine exactement au bord
|
||
haut de la carte. Avec un %, le fondu finissait SOUS ce bord et le texte
|
||
restait lisible au ras de la carte — l'impression de « passer derrière ». */
|
||
#composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;--fade:34px;padding:var(--fade) 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))}
|
||
/* Réserve sous le fil = hauteur RÉELLE du composeur, mesurée et publiée par
|
||
syncComposer() (08-chat-render.js). Une constante redevient fausse dès que la
|
||
saisie grandit ou qu'une pièce jointe s'affiche, et la fin du fil repasse hors
|
||
d'atteinte. 170px n'est que le repli du tout premier rendu, avant mesure.
|
||
Pas de env(safe-area-inset-bottom) ici : il est déjà DANS le padding bas du
|
||
composeur, donc dans sa hauteur mesurée — le compter deux fois creusait un
|
||
trou en PWA iOS. */
|
||
#chat{padding-bottom:calc(var(--composer-h,170px) + 12px)}
|
||
#scrollbtn{bottom:calc(var(--composer-h,170px) + 10px);border-radius:999px}
|
||
#inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius: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;white-space:nowrap}
|
||
#ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)}
|
||
#ctx-compact:hover{border-color:var(--dim);color:var(--text)}
|
||
/* Jauge = bord bas de la carte : épaisse, plaquée au ras du bord, rognée par
|
||
l'arrondi du pied de carte. */
|
||
#ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
|
||
#ctx-fill{background:var(--text);opacity:.75}
|
||
#sendhint{order:4;padding:12px var(--col) 2px}
|
||
|
||
/* ===== Pièces jointes ==============================================
|
||
Un fichier joint n'est jamais lu par la page : il est déposé dans le dossier
|
||
de travail de l'IA et seul son nom revient. L'affichage se limite donc à une
|
||
pastille — pas d'aperçu, pas de vignette, rien qui promette plus que ça.
|
||
|
||
Le trombone vit dans le PIED de la carte, à droite avec le décompte de contexte
|
||
et « compacter ». Il est à LEUR échelle (une ligne d'outils de 10 px), pas à
|
||
celle de « send » : joindre un fichier est un geste occasionnel. */
|
||
#attach{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
|
||
display:flex;align-items:center;justify-content:center;border-radius:999px;
|
||
background:transparent;border:1px solid transparent;color:var(--dim)}
|
||
#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||
#attach:active{transform:scale(.9)}
|
||
#attach svg{display:block}
|
||
/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte.
|
||
Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de
|
||
dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant
|
||
le rembourrage. La carte ne bouge donc pas — c'est la bande vide qui se
|
||
remplit, au lieu du composeur qui grandit vers le haut.
|
||
Elles ne vont PAS dans le pied : cette ligne appartient au nom du modèle. */
|
||
#attach-list{order:0;display:none;gap:6px;margin:0 var(--col);padding:0 2px 8px;
|
||
overflow-x:auto;scrollbar-width:none}
|
||
#attach-list::-webkit-scrollbar{display:none}
|
||
#attach-list.show{display:flex}
|
||
#composer:has(#attach-list.show){--fade:6px}
|
||
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
|
||
cadre, sinon le texte de la conversation transparaît derrière. */
|
||
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
|
||
box-shadow:0 2px 10px rgba(0,0,0,.10)}
|
||
/* Pastille de fichier. Le × n'apparaît qu'au survol : au repos la pastille doit
|
||
rester lisible d'un coup d'œil (nom + poids), pas encombrée d'un bouton. */
|
||
.chip-file{display:flex;align-items:center;gap:8px;max-width:230px;min-width:0;
|
||
padding:4px 12px;border-radius:999px;background:var(--row-bg-on);
|
||
border:1px solid transparent;font-size:12px;line-height:1.3}
|
||
/* Avec un × à droite, la pastille se ferme SUR lui : le rembourrage de droite
|
||
revient à l'épaisseur du rond, sinon le bouton flotte loin du bord et la
|
||
pastille paraît de travers. */
|
||
.chip-file:has(button){padding-right:4px}
|
||
.chip-file .cf-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.chip-file .cf-size{flex:none;color:var(--dim);font-size:11px}
|
||
/* Le × est TOUJOURS visible : le révéler au survol le rendait inatteignable sur
|
||
mobile, où il n'y a pas de survol — donc impossible de retirer un fichier. */
|
||
.chip-file button{flex:none;margin:0;padding:0;width:20px;height:20px;font-size:14px;
|
||
display:flex;align-items:center;justify-content:center;line-height:1;
|
||
border-radius:50%;border:none;background:transparent;color:var(--dim)}
|
||
.chip-file button:hover{background:var(--bubble);color:var(--text)}
|
||
/* Transfert en cours : un liseré qui tourne, plutôt qu'une pastille grisée dont
|
||
on ne sait pas si elle travaille ou si elle a échoué. */
|
||
.chip-file.up .cf-size::after{content:"";display:inline-block;vertical-align:-2px;
|
||
margin-left:7px;width:10px;height:10px;border-radius:50%;
|
||
border:1.5px solid var(--border);border-top-color:var(--text);animation:spin .7s linear infinite}
|
||
.chip-file.err{background:var(--danger-bg);color:var(--err)}
|
||
.chip-file.err .cf-size{color:var(--err)}
|
||
@media (prefers-reduced-motion:reduce){.chip-file.up .cf-size::after{animation:none}}
|
||
/* Fichiers d'un message envoyé : leur propre rangée AU-DESSUS de la bulle,
|
||
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
|
||
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
|
||
une seule bulle difforme qui déborde vers le haut. */
|
||
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
|
||
gap:6px;max-width:75%}
|
||
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
|
||
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
|
||
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
|
||
#chat .msg .filelink{cursor:pointer}
|
||
#chat .msg .filelink::after{content:"↓";margin-left:5px;font-size:.9em;opacity:.7;text-decoration:none;display:inline-block}
|
||
#chat .msg .filelink.busy{opacity:.55;pointer-events:none}
|
||
/* Voile de dépôt. Il couvre la zone de conversation, curseur inclus : c'est la
|
||
réponse à « est-ce que je peux lâcher ici ? », elle doit être sans ambiguïté. */
|
||
#dropveil{position:absolute;inset:0;z-index:20;display:none;align-items:center;
|
||
justify-content:center;padding:24px;background:rgba(0,0,0,.28);pointer-events:none}
|
||
#dropveil.show{display:flex}
|
||
#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:20px;
|
||
background:var(--panel);color:var(--text);font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
|
||
button,.mem-search{border-radius: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{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)}
|
||
|
||
|
||
/* Curseur de portée de l'export. Même vocabulaire visuel que la répartition GPU
|
||
(piste arrondie + pastille cerclée), mais une seule valeur : la position tout
|
||
à droite vaut « toute la conversation ». Le libellé sous la piste donne le
|
||
compte RÉEL d'échanges et le poids du fichier, la question qu'on se pose
|
||
vraiment avant d'exporter. */
|
||
.xr{position:relative;display:flex;align-items:center;height:22px}
|
||
.xr-track{width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)}
|
||
.xr-fill{height:100%;background:var(--accent);border-radius:999px;transition:width .12s}
|
||
.xr-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize}
|
||
.xr-range:focus{outline:none}
|
||
.xr-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none}
|
||
.xr-range::-moz-range-track{height:22px;background:transparent;border:none}
|
||
.xr-range::-moz-range-progress{background:transparent}
|
||
.xr-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize}
|
||
.xr-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize}
|
||
.xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
|
||
.xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
|
||
.xr-range:disabled{cursor:default;opacity:.5}
|
||
/* Conversation vide : un seul message centré à la place des réglages. */
|
||
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
|
||
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
|
||
|
||
/* ===== Direction « Terre » : surfaces en cartes =======================
|
||
La charte pose des CARTES arrondies séparées par une gouttière, au lieu
|
||
d'une colonne à filets. Bloc en fin de feuille pour prendre le pas sur les
|
||
règles de structure d'origine sans les réécrire.
|
||
Bureau UNIQUEMENT : sur téléphone la barre latérale est un tiroir plein
|
||
écran et le fil occupe toute la largeur — y poser des cartes ne ferait que
|
||
grignoter des pixels précieux. */
|
||
@media (min-width:721px){
|
||
/* Coque : le fond « terre » se voit dans la gouttière entre les deux cartes. */
|
||
body{padding:12px;gap:12px}
|
||
html[data-pwa="1"] body{padding-top:calc(12px + env(safe-area-inset-top))}
|
||
|
||
/* Barre latérale : pile de cartes, sans filet ni bordure de colonne. */
|
||
.side{width:312px;border-right:0;padding:0;background:transparent;
|
||
display:flex;flex-direction:column;gap:12px;overflow-y:auto;overscroll-behavior:contain}
|
||
.sidecard,.side>details{background:var(--panel);border:0;border-top:0;border-radius:20px;
|
||
box-shadow:var(--shadow);margin:0;padding:2px 16px}
|
||
.sidecard{padding:15px 18px 14px;display:flex;flex-direction:column;gap:2px}
|
||
.side>details[open]{padding-bottom:14px}
|
||
.side>details>summary{height:42px}
|
||
/* L'étiquette « Paramètres » n'est plus un filet mais un intertitre entre
|
||
deux cartes : le fond de la coque fait déjà la séparation. */
|
||
.side-sep{border-top:0;margin:10px 0 0;padding:0 6px}
|
||
/* Blocs encastrés DANS une carte : un ton plus chaud, sans bordure — deux
|
||
cadres imbriqués alourdiraient inutilement. */
|
||
.side .slist{background:var(--row-bg);border-color:transparent;border-radius:14px}
|
||
|
||
/* Colonne principale : une seule grande carte qui contient fil et saisie. */
|
||
.main{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);overflow:hidden}
|
||
#chat{padding-top:26px;padding-inline:max(20px,calc(50% - 360px))}
|
||
/* Le dégradé qui fait disparaître le fil sous la carte de saisie doit fondre
|
||
vers le PANNEAU, pas vers le fond de la coque. */
|
||
#composer{background:linear-gradient(to bottom,transparent 0,var(--panel) var(--fade))}
|
||
/* Carte de saisie : posée SUR le panneau, donc un ton au-dessus, et sans
|
||
bordure — c'est l'élévation qui la détache. */
|
||
#inputbar,#ctxmeta{background:var(--row-bg-on);border-color:transparent}
|
||
#input{background:transparent}
|
||
}
|
||
|
||
/* --- Terre : densité et accents ------------------------------------- */
|
||
@media (min-width:721px){
|
||
/* Gouttière resserrée : la charte empile beaucoup de cartes, 12 px entre
|
||
chacune repoussait « Actions » loin sous la ligne de flottaison. */
|
||
.side{gap:8px}
|
||
.side>details>summary{height:38px}
|
||
|
||
/* Discussion active : un cadre sauge COMPLET, mais fin (1 px) — le liseré de
|
||
3 px sur le seul bord gauche se lisait mal et déséquilibrait la ligne. La
|
||
bordure transparente est portée par tous les états : la sélection ne décale
|
||
donc pas le contenu d'un pixel. */
|
||
.side .preset{border:1px solid transparent;border-radius:14px;background:transparent}
|
||
.side .preset.active{background:var(--row-bg-on);border-color:var(--accent)}
|
||
.side .preset:hover{background:var(--row-bg)}
|
||
|
||
/* Bulle de question : coin bas-droit rentré, comme une bulle qui pointe vers
|
||
son auteur. */
|
||
#chat .msg.user{border-radius:20px 20px 6px 20px}
|
||
}
|
||
/* Action principale en sauge : c'est la couleur d'action de la charte, et elle
|
||
se distingue enfin du texte (le bouton reprenait --text, donc un brun sombre
|
||
indistinct du reste). L'arrêt, lui, reste en terre cuite. */
|
||
#send{background:var(--accent);border-color:var(--accent);color:#fff}
|
||
#send:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:#fff;opacity:.88}
|
||
#stop{background:var(--err);border-color:var(--err);color:#fff}
|
||
#stop:hover{background:var(--err);border-color:var(--err);color:#fff;opacity:.88}
|
||
|
||
/* --- Terre : largeur de lecture --------------------------------------
|
||
La mesure était plafonnée à 760 px et centrée sur la fenêtre : sur un écran
|
||
large, le fil se réduisait à une colonne étroite entourée de vide, alors que
|
||
la charte fait courir le texte sur toute la largeur du panneau.
|
||
Une seule variable pilote désormais le fil ET la carte de saisie — ils
|
||
étaient réglés séparément (450 / 380 / 360 px), donc condamnés à diverger.
|
||
La borne haute reste : au-delà, une ligne devient pénible à lire (l'œil perd
|
||
le début de la suivante). 1080 px ≈ 110 caractères, large mais tenable. */
|
||
@media (min-width:721px){
|
||
:root{--measure:min(1080px,100%)}
|
||
#chat{padding-top:26px;padding-inline:max(20px,calc(50% - var(--measure)/2))}
|
||
#composer{--col:calc(max(20px,calc(50% - var(--measure)/2)) + var(--sbw,0px))}
|
||
}
|
||
|
||
/* --- Terre : rangée de bulles techniques ------------------------------
|
||
Raisonnement, outils et mémoire ne portent qu'une étiquette tant qu'ils sont
|
||
repliés : les empiler gaspillait une ligne entière par appel et repoussait la
|
||
réponse hors de l'écran. Ils défilent donc en rangée, et reprennent la pleine
|
||
largeur dès qu'on en déplie un. */
|
||
.toolrow{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start}
|
||
.toolrow>.msg.collapsible{margin:0;max-width:100%}
|
||
.toolrow>.msg.collapsible.collapsed{width:auto}
|
||
/* Dépliée : la bulle passe seule sur sa ligne, sinon son contenu serait
|
||
comprimé dans une colonne étroite à côté d'une pastille. */
|
||
.toolrow>.msg.collapsible:not(.collapsed){flex:1 0 100%}
|
||
|
||
/* --- Terre : sections groupées en cartes ------------------------------
|
||
Une carte par section empilait onze arrondis et autant de gouttières : la
|
||
barre latérale débordait de l'écran alors que la plupart de ces lignes sont
|
||
des titres repliés d'une seule ligne. Les sections d'IA d'un côté, celles de
|
||
l'application de l'autre, tiennent maintenant chacune dans UNE carte, avec un
|
||
filet discret entre les lignes. */
|
||
@media (min-width:721px){
|
||
.sidegroup{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||
padding:2px 16px;display:flex;flex-direction:column}
|
||
/* Dans un groupe, les sections ne sont plus des cartes : elles redeviennent
|
||
des lignes, séparées par un filet — sauf la première. */
|
||
.sidegroup>details{background:transparent;box-shadow:none;border-radius:0;padding:0;
|
||
border-top:1px solid var(--border)}
|
||
.sidegroup>details:first-child{border-top:0}
|
||
.sidegroup>details[open]{padding-bottom:12px}
|
||
}
|
||
|
||
/* --- Recherche Hugging Face -------------------------------------------
|
||
Deux listes successives dans la même zone (dépôts, puis fichiers du dépôt
|
||
choisi) : une seule mise en forme sert aux deux, sinon elles divergent. La
|
||
liste est bornée en hauteur et défile — un dépôt publie facilement vingt
|
||
quantifications, et la modale ne doit pas s'étirer jusqu'à sortir de
|
||
l'écran. */
|
||
.hf-list{display:flex;flex-direction:column;max-height:220px;overflow-y:auto;
|
||
border:1px solid var(--border);border-radius:12px;margin-top:6px}
|
||
.hf-row{display:flex;align-items:center;gap:10px;padding:7px 12px;cursor:pointer;
|
||
border-top:1px solid var(--border);font-size:12px}
|
||
.hf-row:first-child{border-top:0}
|
||
.hf-row:hover{background:var(--row-bg-on)}
|
||
/* Le nom prend la place restante et se tronque : un identifiant de dépôt long
|
||
ne doit pas pousser la taille et le verdict hors de la ligne. */
|
||
.hf-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
||
.hf-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap}
|
||
/* Verdict mémoire. La couleur seule ne suffit pas — le mot est écrit dedans,
|
||
et l'explication complète est dans l'infobulle. */
|
||
.hf-fit{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em;
|
||
text-transform:uppercase;border:1px solid transparent}
|
||
.hf-fit.ok{color:var(--ok);border-color:var(--ok)}
|
||
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
|
||
.hf-fit.trop{color:var(--err);border-color:var(--err)}
|
||
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
|
||
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
|
||
color:var(--dim);cursor:pointer}
|
||
.hf-mm.muted{cursor:default}
|
||
|
||
/* --- Panneau « Fichiers » (droite) ------------------------------------
|
||
Symétrique des réglages à gauche : une colonne fixe qui pousse la
|
||
conversation au lieu de la recouvrir. C'est possible ici parce que la largeur
|
||
de lecture est pilotée par --measure et que #chat se recentre tout seul dans
|
||
la place qui reste — le fil se resserre, il ne passe pas dessous.
|
||
Le panneau est retiré du flux quand il est fermé (et non simplement décalé) :
|
||
sinon il conserverait sa gouttière et le fil resterait décentré. */
|
||
#files{display:none;width:320px;flex-shrink:0;border-left:1px solid var(--border);
|
||
background:var(--panel);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));
|
||
overflow-y:auto;flex-direction:column;gap:10px}
|
||
html[data-files="1"] #files{display:flex}
|
||
#files-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:14}
|
||
.files-head{display:flex;align-items:center;gap:8px}
|
||
.files-head .stitle{flex:1}
|
||
#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
|
||
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:8px}
|
||
#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
|
||
/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
|
||
discussion dans le chemin dépasse largement 320 px. */
|
||
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px}
|
||
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:7px;
|
||
background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
|
||
.files-crumb-b.on{color:var(--text);cursor:default}
|
||
.files-crumb-s{color:var(--dim);opacity:.5}
|
||
.files-list{display:flex;flex-direction:column;flex:1;min-height:0}
|
||
.files-empty{font-size:12px;line-height:1.5;padding:10px 2px}
|
||
/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
|
||
survol : au repos on veut lire des noms de fichiers, pas une grille de
|
||
boutons. Sur écran tactile (pas de survol) elles restent visibles. */
|
||
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;font-size:12px}
|
||
.files-row:hover{background:var(--row-bg-on)}
|
||
.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
|
||
.files-row.isdir .files-ic{color:var(--accent)}
|
||
.files-mid{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
|
||
.files-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
||
.files-meta{font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
|
||
.files-acts{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
||
.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
|
||
@media (hover:none){.files-acts{opacity:1}}
|
||
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
|
||
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
|
||
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border);
|
||
display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
/* Détour « hors discussion » : discret, en pied de panneau — c'est une sortie de
|
||
secours pour les fichiers d'avant le rangement par discussion, pas une vue
|
||
qu'on propose au même rang que la discussion ouverte. */
|
||
.files-scope{margin:0;padding:2px 6px;min-height:0;font-family:var(--mono);font-size:10px;
|
||
background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--dim)}
|
||
.files-scope:hover{color:var(--text);background:var(--row-bg-on)}
|
||
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
|
||
.files-row.busy{opacity:.55;pointer-events:none}
|
||
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand
|
||
le panneau est ouvert — l'état se lit sans regarder à droite. */
|
||
#files-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
|
||
display:flex;align-items:center;justify-content:center;border-radius:999px;
|
||
background:transparent;border:1px solid transparent;color:var(--dim)}
|
||
#files-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||
#files-btn:active{transform:scale(.9)}
|
||
#files-btn svg{display:block}
|
||
html[data-files="1"] #files-btn{color:var(--accent)}
|
||
/* Petit écran : tiroir par la droite, comme les réglages par la gauche. Pousser
|
||
une colonne de 320 px sur un téléphone ne laisserait rien à la conversation. */
|
||
@media (max-width:720px){
|
||
#files{position:fixed;inset:0 0 0 auto;width:85%;max-width:340px;z-index:15;
|
||
background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));
|
||
padding-right:calc(16px + env(safe-area-inset-right))}
|
||
html[data-files="1"] #files-backdrop{display:block}
|
||
}
|
||
@media (min-width:721px){
|
||
/* Dans la charte « Terre », le panneau est une carte comme les autres. */
|
||
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
|
||
box-shadow:var(--shadow);margin:0}
|
||
}
|
||
</style></head><body>
|
||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
||
fermé sur téléphone. Sans ce rappel, un rechargement de page donnait
|
||
l'impression que l'installation avait disparu. -->
|
||
<button id="lc-chip" onclick="lcChipOpen()" hidden></button>
|
||
<div id="backdrop" onclick="toggleSide()"></div>
|
||
<div class="side" id="side">
|
||
<!-- sidecard : regroupe la marque et la mention du fork en UNE carte (direction
|
||
Terre). Sans ce conteneur, les deux blocs flottaient séparément. -->
|
||
<div class="sidecard">
|
||
<div class="sidehead">
|
||
<h1 id="brand" aria-label="loki" title="Loki — fork d'AJEAN (github.com/nathaninline/ajean, MIT)"><span id="brand-a"><svg viewBox="0 0 4 5" shape-rendering="crispEdges" fill="currentColor"><rect x="0" y="0" width="1" height="5"/><rect x="1" y="4" width="3" height="1"/></svg></span><span id="brand-word"><svg viewBox="0 0 11 5" shape-rendering="crispEdges" fill="currentColor"><rect x="0" y="0" width="3" height="1"/><rect x="0" y="1" width="1" height="3"/><rect x="2" y="1" width="1" height="3"/><rect x="0" y="4" width="3" height="1"/><rect x="4" y="0" width="1" height="5"/><rect x="6" y="0" width="1" height="2"/><rect x="5" y="2" width="1" height="1"/><rect x="6" y="3" width="1" height="2"/><rect x="8" y="0" width="3" height="1"/><rect x="9" y="1" width="1" height="3"/><rect x="8" y="4" width="3" height="1"/></svg></span><span id="ver"></span></h1>
|
||
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
||
</div>
|
||
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
|
||
</div><!-- /sidecard -->
|
||
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
|
||
actif est partagé par tous les appareils (comme la conversation unique
|
||
d'origine) : basculer ici bascule partout. -->
|
||
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion">+</button></summary>
|
||
<div id="conv-list" class="slist"></div>
|
||
</details>
|
||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius: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
|
||
OU par son propre chevron — c'est une section comme les autres, sinon on ne
|
||
trouvait pas comment la refermer.
|
||
C'est le seul endroit où l'on voit pourquoi un modèle refuse de charger. -->
|
||
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
||
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
||
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
||
<div class="sidegroup">
|
||
<details id="svc-log-box" ontoggle="if(this.open){loadSvcLog();showPaths()}"><summary><span class="stitle">Journal du moteur</span></summary>
|
||
<div class="row" style="justify-content:flex-end;align-items:center">
|
||
<button onclick="loadSvcLog()" title="rafraîchir">↻</button>
|
||
</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>
|
||
</details>
|
||
<!-- GPU et RAM décrivent la même chose (les ressources de la machine) :
|
||
une seule section, deux blocs de statistique dans la même carte. -->
|
||
<details open><summary><span class="stitle">Machine</span></summary>
|
||
<div class="slist">
|
||
<div id="vram">…</div>
|
||
<div id="ram-details"><div id="ram"></div></div>
|
||
</div>
|
||
</details>
|
||
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
|
||
<div id="cfg" class="slist">…</div>
|
||
</details>
|
||
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter">+</button></summary>
|
||
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
||
</details>
|
||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
||
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
||
entier est grisé/inerte quand l'agent est off (voir setAgentGate). -->
|
||
<div id="mem-block" class="gated-group">
|
||
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
|
||
<div class="slist">
|
||
<label class="switchrow"><span>mode</span>
|
||
<select id="mem-mode" onchange="setMemMode()" class="sctl">
|
||
<option value="always">auto</option>
|
||
<option value="ondemand">sur demande</option>
|
||
<option value="off">désactivée</option>
|
||
</select>
|
||
</label>
|
||
<div id="mem-pages-bar" class="pages-bar" onclick="toggleMemPages()">
|
||
<span class="pages-title">Voir la mémoire <span id="mem-count" class="mem-count"></span></span>
|
||
<button class="addbtn" onclick="event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button>
|
||
</div>
|
||
<div id="mem-pages-body" hidden>
|
||
<!-- Le champ de filtre garde son rembourrage de ligne ; la liste, elle,
|
||
est à fleur de carte : ses pages sont des LIGNES séparées par un
|
||
filet, pas une pile de petites cartes. -->
|
||
<div id="mem-search-row" class="rowbody" style="display:none">
|
||
<input id="mem-search" class="mem-search" type="search" placeholder="filtrer la mémoire…" oninput="renderMemList()" style="margin:0">
|
||
</div>
|
||
<div id="mem-list" class="mem-list rowlist muted" style="font-size:12px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="net-block" class="gated-group">
|
||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||
<div class="slist">
|
||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
|
||
JavaScript) · « crawl4ai » = serveur externe avec Chromium headless.
|
||
Réglages propres à Crawl4AI masqués avec le moteur intégré ; l'URL et
|
||
la clé s'enregistrent en quittant le champ (onchange). -->
|
||
<div class="rowbody">
|
||
<div class="row" style="margin:0">
|
||
<select id="web-engine" class="sctl" onchange="saveWebEngine()">
|
||
<option value="go">Moteur Intégré</option>
|
||
<option value="crawl4ai">Crawl4AI</option>
|
||
</select>
|
||
</div>
|
||
<!-- Masqués AU DÉPART : le moteur par défaut est l'intégré, et ces champs
|
||
ne concernent que Crawl4AI. Affichés d'emblée puis repliés par le
|
||
chargement de l'état, ils faisaient sauter la section sous les yeux. -->
|
||
<div id="crawl-fields" style="display:none">
|
||
<div class="row">
|
||
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
|
||
</div>
|
||
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
|
||
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
|
||
qu'un indice sur les 4 derniers caractères. -->
|
||
<div class="row">
|
||
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
|
||
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
|
||
</div>
|
||
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
|
||
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
|
||
</div>
|
||
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="mcp-block" class="gated-group">
|
||
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
|
||
<div class="slist">
|
||
<div class="rowbody">
|
||
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
|
||
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
|
||
<div id="mcp-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div></div>
|
||
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="param-block" class="gated-group">
|
||
<div class="subhead">Paramètres</div>
|
||
<div class="slist">
|
||
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details><summary><span class="stitle">System prompt</span></summary>
|
||
<div class="slist"><div class="rowbody">
|
||
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
|
||
</div></div>
|
||
</details>
|
||
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
|
||
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||
étaient des cartes dans une carte). -->
|
||
<div class="slist">
|
||
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
|
||
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
|
||
<div id="lc-provided" class="rowbody" style="display:none">
|
||
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
|
||
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
|
||
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
|
||
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
||
</div>
|
||
</div>
|
||
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
|
||
la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
|
||
donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
|
||
alors que compiler donne du CUDA natif. -->
|
||
<div class="lc-modes">
|
||
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
|
||
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
|
||
<div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
|
||
<div class="lc-mode-state" id="lc-fast-state"></div>
|
||
</div>
|
||
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
|
||
<div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
|
||
<div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
|
||
<div class="lc-mode-state" id="lc-opt-state"></div>
|
||
</div>
|
||
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
|
||
<div class="lc-mode-t">llama.cpp personnalisé</div>
|
||
<div class="lc-mode-d">un fork de llama.cpp pour modèles spéciaux (quant ternaire…)</div>
|
||
<div class="lc-mode-state" id="lc-custom-state"></div>
|
||
</div>
|
||
</div>
|
||
<div id="lc-job" class="rowbody" style="display:none">
|
||
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
|
||
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
|
||
<pre id="lc-log" hidden></pre>
|
||
</div>
|
||
</div>
|
||
<!-- Ces 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"><div class="rowbody">
|
||
<div class="row" style="margin:0">
|
||
<button onclick="act('start')">démarrer</button>
|
||
<button onclick="act('restart')">redémarrer</button>
|
||
<button onclick="act('stop')">arrêter</button>
|
||
</div>
|
||
</div></div>
|
||
</details>
|
||
</div><!-- /sidegroup -->
|
||
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
||
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
||
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
||
l'application elle-même. -->
|
||
<div class="side-sep">Paramètres</div>
|
||
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
||
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
||
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
||
<div class="sidegroup">
|
||
<details id="ident-details"><summary><span class="stitle">Identité</span></summary>
|
||
<div class="slist"><div class="rowbody">
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
|
||
<!-- autocomplete/data-*-ignore : sans ça, les gestionnaires de mots de
|
||
passe prennent ce champ pour un identifiant de connexion et
|
||
recouvrent la saisie de comptes enregistrés. -->
|
||
<span class="pe-row-c"><input id="p-username" class="pe-inp" maxlength="24" placeholder="toi"
|
||
autocomplete="off" name="loki-nickname" data-1p-ignore data-lpignore="true" data-form-type="other"
|
||
oninput="saveIdentity()"></span>
|
||
</div>
|
||
<div class="pe-row stack">
|
||
<span class="pe-row-l">Ton avatar</span>
|
||
<div id="p-avatar-user" class="avpick"></div>
|
||
</div>
|
||
<div class="pe-row stack">
|
||
<span class="pe-row-l">Avatar de Loki</span>
|
||
<div id="p-avatar-ai" class="avpick"></div>
|
||
</div>
|
||
</div></div>
|
||
</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">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>
|
||
<!-- UNE carte pour tout l'endpoint : adresse locale, bascule « public » et
|
||
adresse publique sont un seul sujet — trois cartes accolées donnaient
|
||
trois îlots pour un même réglage. -->
|
||
<div class="slist">
|
||
<div class="rowbody">
|
||
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>loki</code></div>
|
||
<div class="row" style="margin:0">
|
||
<input id="oai-url" readonly class="io-input">
|
||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||
</div>
|
||
</div>
|
||
<!-- L'adresse ci-dessus ne sert à RIEN si le moteur n'écoute que sur la
|
||
boucle locale (le défaut sous Windows) : le chat du navigateur marche,
|
||
mais aucun logiciel du réseau ne peut s'y brancher. Cet interrupteur
|
||
règle HOST et pose la règle de pare-feu, voir sys_network.go. -->
|
||
<label class="switchrow"><span class="switch"><input type="checkbox" id="lan-toggle" onchange="toggleLAN()"><span class="slider"></span></span> joignable depuis le réseau local<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Éteint, le moteur n'écoute que sur <b>cette machine</b> : l'adresse ci-dessus ne répond qu'ici. Allumez-le pour brancher un logiciel depuis un autre ordinateur du réseau (sous Windows, la règle de pare-feu est posée en même temps). <b>Redémarre le moteur.</b></span></span></label>
|
||
<div id="lan-warn" class="rowbody" style="display:none"></div>
|
||
<label class="switchrow"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend votre IA joignable depuis <b>n'importe où</b> via <code>https://<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 id="oai-public-wrap" class="rowbody" style="display:none">
|
||
<div class="muted" style="margin:0 0 6px">Adresse publique — chiffrée de bout en bout, accessible partout</div>
|
||
<div class="row" style="margin:0">
|
||
<input id="oai-public-url" readonly class="io-input">
|
||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-public-url').value,'adresse publique copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé <b>redémarre</b> le service.</span></span></div>
|
||
<div class="slist"><div class="rowbody">
|
||
<div class="row" style="margin:0">
|
||
<input id="oai-key" readonly class="io-input">
|
||
<button id="oai-key-eye" onclick="toggleKeyReveal()" title="afficher / masquer">afficher</button>
|
||
<button class="iconbtn" onclick="copyApiKey()" title="copier la clé"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||
</div>
|
||
<div class="row" style="margin-bottom:0">
|
||
<button onclick="apiKeyAction('generate')">générer</button>
|
||
<button onclick="apiKeySet()">définir…</button>
|
||
<button onclick="apiKeyAction('clear')">retirer</button>
|
||
</div>
|
||
</div></div>
|
||
</details>
|
||
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
|
||
<div class="slist"><div class="rowbody">
|
||
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
|
||
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
|
||
bouton exporter, plus personne ne peut relire/archiver ses échanges. -->
|
||
<div class="row" style="margin:0">
|
||
<button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button>
|
||
<button onclick="openExportModal()" title="choisir le format et ce qu'on emporte">⤓ exporter…</button>
|
||
</div>
|
||
<div id="upd-msg" class="muted"></div>
|
||
<!-- Ligne 2 : bench + clear chat (seule action destructive, gardée en dernier). -->
|
||
<div class="row" style="margin-bottom:0">
|
||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||
<button onclick="resetChat()">⌫ clear chat</button>
|
||
</div>
|
||
</div></div>
|
||
</details>
|
||
</div><!-- /sidegroup -->
|
||
</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>
|
||
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
|
||
le replay terminé, sinon il clignoterait au chargement de chaque page).
|
||
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
|
||
pour le dessin des lettres. -->
|
||
<div id="chat-empty" aria-hidden="true">
|
||
<div id="ce-logo"></div>
|
||
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
|
||
pas de civilité ni d'accord qui obligerait à deviner. -->
|
||
<div id="ce-title">On commence par quoi ?</div>
|
||
</div>
|
||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
|
||
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
|
||
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
|
||
<div id="composer">
|
||
<div id="ctxmeta">
|
||
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
||
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
||
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
|
||
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
|
||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||
<span id="ctx-text" class="muted">0 / 0</span>
|
||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
|
||
gabarit que le trombone — ce sont les deux faces du même sujet,
|
||
ce qui entre et ce qui sort. -->
|
||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||
</button>
|
||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
|
||
dont le rendu varie énormément d'un système à l'autre. -->
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.4 11.05 12.25 20.2a5.5 5.5 0 0 1-7.78-7.78l9.19-9.19a3.67 3.67 0 1 1 5.18 5.18l-9.2 9.2a1.83 1.83 0 1 1-2.59-2.6l8.49-8.48"/></svg>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
||
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
||
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
||
<div id="attach-list"></div>
|
||
<div id="inputbar">
|
||
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
||
<button id="send" onclick="send()">send</button>
|
||
<button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
|
||
</div>
|
||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||
</div>
|
||
</div>
|
||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||
utile à personne. -->
|
||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||
<aside id="files" aria-label="Fichiers de cette discussion">
|
||
<div class="files-head">
|
||
<span class="stitle">Fichiers de la discussion</span>
|
||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
|
||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
|
||
</div>
|
||
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
||
captures), il faut pouvoir y descendre et en remonter. -->
|
||
<div id="files-crumb" class="files-crumb"></div>
|
||
<div id="files-list" class="files-list"></div>
|
||
<div id="files-foot" class="files-foot muted"></div>
|
||
</aside>
|
||
<div id="toast"></div>
|
||
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius: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>
|
||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
||
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
||
<div class="modal-card" style="max-width:460px">
|
||
<div class="modal-head">
|
||
<strong>Exporter la conversation</strong>
|
||
<button class="modal-x" onclick="closeExportModal()" title="fermer">×</button>
|
||
</div>
|
||
<!-- Conversation vide : pas d'options à régler, un seul message. Proposer
|
||
des réglages pour un fichier qui ne contiendrait rien n'a aucun sens. -->
|
||
<div id="x-empty" class="xempty" style="display:none">
|
||
<div class="xempty-i">⤓</div>
|
||
<div>Rien à exporter</div>
|
||
<div class="pe-note">La conversation est vide.</div>
|
||
</div>
|
||
<div id="x-body" class="pe-body" style="gap:20px">
|
||
<div class="pe-group">
|
||
<div class="pe-gh">Format</div>
|
||
<div class="pe-seg">
|
||
<label><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()"><span>Markdown</span><span class="be-note">à lire</span></label>
|
||
<label><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()"><span>JSON</span><span class="be-note">à retraiter</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="pe-group">
|
||
<div class="pe-gh">Portée</div>
|
||
<div class="pe-list">
|
||
<div class="pe-row stack">
|
||
<div class="xr">
|
||
<div class="xr-track"><div class="xr-fill" id="x-fill"></div></div>
|
||
<input type="range" class="xr-range" id="x-turns" min="1" max="1" value="1" oninput="onExportPreview()" aria-label="nombre d'échanges à exporter">
|
||
</div>
|
||
<div class="pe-note" id="x-scope"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="pe-group">
|
||
<div class="pe-gh">Contenu</div>
|
||
<div class="pe-list">
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Raisonnements<span class="pe-sub">la réflexion du modèle, souvent plus longue que la réponse</span></span>
|
||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-reasoning" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Appels d'outils<span class="pe-sub">commandes, lectures et écritures de fichiers</span></span>
|
||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-tools" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Sorties des outils<span class="pe-sub">ce que chaque outil a répondu</span></span>
|
||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-results" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="pe-note" id="x-note"></div>
|
||
</div>
|
||
<div class="modal-foot" style="justify-content:flex-end">
|
||
<button class="pe-btn" onclick="closeExportModal()">annuler</button>
|
||
<button class="pe-btn primary" id="x-go" onclick="runExport()">⤓ télécharger</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="ask-modal" class="ask-overlay" style="display:none" onclick="if(event.target===this)askResolve(false)">
|
||
<div class="ask-box">
|
||
<div id="ask-title" class="ask-title"></div>
|
||
<div id="ask-msg" class="ask-msg"></div>
|
||
<input id="ask-input" class="ask-input" style="display:none">
|
||
<!-- Option facultative de la confirmation (ex. « supprimer aussi le .gguf ») :
|
||
sa place est ICI, dans la boîte qui pose la question, plutôt que dans le
|
||
pied de l'éditeur où elle occupait de la place en permanence. -->
|
||
<label id="ask-check" class="pe-delmodel" style="display:none"><input type="checkbox" id="ask-check-input"><span id="ask-check-label"></span></label>
|
||
<div class="ask-actions">
|
||
<button id="ask-cancel" onclick="askResolve(false)">Annuler</button>
|
||
<button id="ask-ok" class="ask-ok" onclick="askResolve(true)">Confirmer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
|
||
<div class="modal-card" style="max-width:560px">
|
||
<div class="modal-head">
|
||
<strong>Backends personnalisés</strong>
|
||
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</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>
|
||
<!-- Chargement : la modale s'ouvre tout de suite, mais son contenu n'arrive
|
||
qu'après (jusqu'à 3 requêtes en accès distant). Ce voile se pose PAR-DESSUS
|
||
le formulaire, qui reste en place (invisible) : la carte garde sa taille
|
||
finale, donc pas de saut au moment où le contenu apparaît. -->
|
||
<div class="modal-load"><span class="spinner"></span></div>
|
||
<div class="pe-body">
|
||
<div class="pe-group">
|
||
<div class="pe-gh">Nom</div>
|
||
<div class="pe-list">
|
||
<div class="pe-row stack plain"><input id="m-name" class="pe-inp" placeholder="Nom du preset"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="m-model-row" style="display:none;flex-direction:column;gap:26px">
|
||
<div class="pe-group">
|
||
<div class="pe-gh">Modèle</div>
|
||
<div class="pe-list">
|
||
<div class="pe-row stack">
|
||
<span class="pe-row-l">Fichier</span>
|
||
<span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
|
||
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
|
||
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
|
||
</div>
|
||
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
|
||
reste là : c'est le mode expert (dépôt privé, fichier exotique,
|
||
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
|
||
surtout il propose le projecteur vision DU MÊME DÉPÔT — le seul
|
||
qui corresponde au modèle choisi. -->
|
||
<div class="pe-row stack">
|
||
<span class="pe-row-l">Chercher un modèle<span class="pe-sub">sur Hugging Face — dépôts GGUF uniquement</span></span>
|
||
<div class="pe-inpbtn">
|
||
<input id="hf-q" class="pe-inp" placeholder="qwen3.8, mistral, gemma…" onkeydown="if(event.key==='Enter'){event.preventDefault();hfSearch();}">
|
||
<button id="hf-go" onclick="hfSearch()" title="Chercher">⌕</button>
|
||
</div>
|
||
<div id="hf-out"></div>
|
||
</div>
|
||
<div class="pe-row stack">
|
||
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">ou un projecteur mmproj — lien direct vers un .gguf</span></span>
|
||
<div class="pe-inpbtn">
|
||
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
|
||
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
|
||
</div>
|
||
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
|
||
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
|
||
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Destination</span>
|
||
<span class="pe-selc"><select id="m-hf-dir"></select></span>
|
||
</div>
|
||
<div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()">
|
||
<span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span>
|
||
<span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span>
|
||
</div>
|
||
<div id="m-dirs-body" class="pe-row stack" style="display:none">
|
||
<div id="m-dirs-list" class="pe-note"></div>
|
||
<div class="pe-inpbtn">
|
||
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
|
||
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
|
||
</div>
|
||
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pe-group">
|
||
<div class="pe-gh">Moteur</div>
|
||
<!-- « Image » n'apparaît qu'en conteneur (moteur fourni), où
|
||
« Précompilé » et « Compilé » sont au contraire masqués : ils
|
||
désignent des installations que Loki ne fait pas ici. -->
|
||
<div class="pe-seg">
|
||
<label id="be-lab-image" style="display:none"><input type="radio" name="m-be" value="image" onchange="onBackendMode('image')"><span>Image</span><span class="be-note" id="be-image-note"></span></label>
|
||
<label id="be-lab-fast"><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Précompilé</span><span class="be-note" id="be-fast-note"></span></label>
|
||
<label id="be-lab-opt"><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Compilé</span><span class="be-note" id="be-opt-note"></span></label>
|
||
<label><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
|
||
</div>
|
||
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Backend</span>
|
||
<span class="pe-row-c"><span class="pe-selc"><select id="m-backend-detected" onchange="onPickDetected()"></select></span></span>
|
||
</div>
|
||
<div class="pe-row stack">
|
||
<input id="m-backend-path" class="pe-inp" oninput="onCustomPath()" placeholder="ou un chemin vers llama-server(.exe)">
|
||
<div class="pe-note" id="be-drop-hint"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cartes graphiques : la liste vient du moteur choisi juste au-dessus,
|
||
car les noms ET l'ordre dépendent du backend compilé dedans. Le
|
||
groupe reste masqué tant qu'on n'a pas AU MOINS DEUX cartes : avec
|
||
une seule, il n'y a rien à arbitrer. -->
|
||
<div class="pe-group" id="m-gpu-group" hidden>
|
||
<div class="pe-gh">Cartes graphiques</div>
|
||
<div class="pe-list" id="m-gpu-list"></div>
|
||
<div class="pe-note" id="m-gpu-note"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Une seule liste : les anciens « réglages avancés » repliés sont
|
||
désormais à la suite. Les valeurs d'abord, puis TOUS les interrupteurs
|
||
groupés en fin de liste — chercher un toggle perdu entre deux champs
|
||
numériques n'aidait personne. -->
|
||
<div id="m-settings-row" class="pe-group" style="display:none">
|
||
<div class="pe-gh">Réglages</div>
|
||
<div class="pe-list">
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Contexte</span>
|
||
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 = tout sur le GPU · auto = ce qui tient en VRAM</span></span>
|
||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non</span></span>
|
||
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Threads CPU<span class="pe-sub">0 = auto</span></span>
|
||
<span class="pe-row-c"><input id="s-threads" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS', this.value)"></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Threads (batch)<span class="pe-sub">0 = auto</span></span>
|
||
<span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">BATCH</span>
|
||
<span class="pe-row-c"><input id="s-batch" class="pe-val" type="number" min="0" step="256" placeholder="2048" oninput="cfgWriteKey('BATCH', this.value)"></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">UBATCH</span>
|
||
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Cache KV<span class="pe-sub">compression du contexte</span></span>
|
||
<span class="pe-row-c"><span class="pe-selc"><select id="s-kv" onchange="cfgWriteKey('KV_TYPE', this.value)">
|
||
<option value="">f16 (max qualité)</option>
|
||
<option value="q8_0">q8_0 (léger)</option>
|
||
<option value="q4_0">q4_0 (très léger)</option>
|
||
<option value="q5_1">q5_1</option>
|
||
<option value="bf16">bf16</option>
|
||
</select></span></span>
|
||
</div>
|
||
<!-- Décodage spéculatif : le type vient de la liste que llama-server
|
||
accepte pour --spec-type. Le nombre de jetons anticipés
|
||
(--spec-draft-n-max) n'a de sens qu'une fois un type choisi, donc
|
||
sa ligne n'apparaît qu'à ce moment. -->
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Décodage spéculatif<span class="pe-sub">anticipe des jetons pour accélérer</span></span>
|
||
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
|
||
<option value="">non</option>
|
||
<optgroup label="Modèle brouillon">
|
||
<option value="draft-mtp">MTP (intégré au modèle)</option>
|
||
<option value="draft-eagle3">EAGLE-3</option>
|
||
<option value="draft-simple">modèle brouillon séparé</option>
|
||
<option value="draft-dflash">dFlash</option>
|
||
<option value="draft-dspark">dSpark</option>
|
||
</optgroup>
|
||
<optgroup label="N-grammes (sans modèle brouillon)">
|
||
<option value="ngram-simple">n-grammes</option>
|
||
<option value="ngram-mod">n-grammes (mod)</option>
|
||
<option value="ngram-cache">n-grammes (cache)</option>
|
||
<option value="ngram-map-k">n-grammes (map-k)</option>
|
||
<option value="ngram-map-k4v">n-grammes (map-k4v)</option>
|
||
</optgroup>
|
||
</select></span></span>
|
||
</div>
|
||
<div class="pe-row" id="s-spec-n-row" style="display:none">
|
||
<span class="pe-row-l">Jetons anticipés<span class="pe-sub">par étape — défaut 3</span></span>
|
||
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
|
||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
|
||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
|
||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
|
||
</div>
|
||
<div class="pe-row">
|
||
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
|
||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="m-raw" class="pe-group">
|
||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||
<div class="pe-list">
|
||
<div id="m-raw-toggle" class="pe-row" style="cursor:pointer" onclick="toggleRaw()">
|
||
<span class="pe-row-l">Éditer le fichier de configuration<span class="pe-sub">texte brut</span></span>
|
||
<span class="pe-row-c"><span id="m-raw-caret" class="pe-caret"></span></span>
|
||
</div>
|
||
<div id="m-raw-body" class="pe-row stack" style="display:none">
|
||
<textarea id="m-content" class="pe-inp" spellcheck="false"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Pied compact, sur UNE ligne : la suppression est une icône (action rare et
|
||
destructrice, elle n'a pas à peser autant que « enregistrer »), et sa case
|
||
« supprimer aussi le .gguf » se réduit à l'extension, le libellé complet
|
||
restant en infobulle. -->
|
||
<div class="pe-foot">
|
||
<div class="foot-l">
|
||
<button id="m-del" class="pe-del" onclick="delItem()" title="supprimer" aria-label="supprimer"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
|
||
</div>
|
||
<div class="foot-r">
|
||
<button class="pe-btn" onclick="closeModal()">annuler</button>
|
||
<button class="pe-btn primary" onclick="saveItem()">enregistrer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script src="marked.min.js"></script>
|
||
<script>
|
||
// marked: GFM on, no auto-breaks (single newlines stay inline).
|
||
marked.setOptions({ gfm: true, breaks: false });
|
||
// Pre-process to fix common LLM markdown mistakes before handing to marked:
|
||
// - code fences glued to preceding text on the same line ("foo ```ruby ...")
|
||
// - fences without a closing newline
|
||
// - 3+ consecutive blank lines collapsed to one (LLMs love padding)
|
||
function fixMd(s){
|
||
if(!s) return '';
|
||
// Force a newline before a fence that's been glued to preceding text
|
||
// (common LLM mistake: "...crée le fichier et```ruby"). We do NOT split
|
||
// *after* the fence — the part after the fence is the language identifier.
|
||
s = s.replace(/([^\n])(\s*)(```+|~~~+)(?=\w*\s*\n)/g, '$1\n$3');
|
||
// Collapse runs of blank lines that LLMs love to emit.
|
||
s = s.replace(/\n{3,}/g, '\n\n');
|
||
return s;
|
||
}
|
||
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
|
||
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
|
||
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
|
||
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
|
||
// modèle. En cours de streaming, la parenthèse fermante manque encore → aucune
|
||
// correspondance, donc pas d'encart qui clignote à chaque token.
|
||
const NOTICE_RE=/\[stop\s*:\s*([^\]]+)\]/g;
|
||
function markNotices(root){
|
||
const walk=document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||
const hits=[];
|
||
while(walk.nextNode()){
|
||
const n=walk.currentNode;
|
||
// Un exemple dans un bloc de code reste du code.
|
||
if(n.parentElement && n.parentElement.closest('pre,code')) continue;
|
||
NOTICE_RE.lastIndex=0;
|
||
if(NOTICE_RE.test(n.nodeValue)) hits.push(n);
|
||
}
|
||
hits.forEach(n=>{
|
||
const frag=document.createDocumentFragment();
|
||
let rest=n.nodeValue, m;
|
||
NOTICE_RE.lastIndex=0;
|
||
while((m=NOTICE_RE.exec(rest))!==null){
|
||
if(m.index) frag.appendChild(document.createTextNode(rest.slice(0,m.index)));
|
||
const tag=document.createElement('span');
|
||
tag.className='stopnote'; tag.textContent=m[1].trim();
|
||
frag.appendChild(tag);
|
||
rest=rest.slice(m.index+m[0].length);
|
||
NOTICE_RE.lastIndex=0;
|
||
}
|
||
if(rest) frag.appendChild(document.createTextNode(rest));
|
||
n.parentNode.replaceChild(frag, n);
|
||
});
|
||
}
|
||
let msgs = [];
|
||
let busy = false;
|
||
function toggleSide(){ document.getElementById('side').classList.toggle('open'); document.getElementById('backdrop').classList.toggle('open'); document.body.classList.toggle('drawer-open'); }
|
||
// Prompt système : persisté CÔTÉ SERVEUR (/api/sysprompt, partagé entre
|
||
// appareils, utilisé par la génération serveur). localStorage = simple cache
|
||
// d'affichage hors-ligne. Debounce : on n'écrit qu'après une pause de frappe.
|
||
let _sysT = null;
|
||
function saveSys(){
|
||
const v = document.getElementById('sysprompt').value;
|
||
localStorage.setItem('loki.sys', v);
|
||
clearTimeout(_sysT);
|
||
_sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600);
|
||
}
|
||
async function loadSys(){
|
||
try{
|
||
const d = await jget('/api/sysprompt');
|
||
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); }
|
||
}catch(e){}
|
||
}
|
||
// ===== Apparence =============================================================
|
||
// Un seul thème, en deux variantes : claire (par défaut) et sombre. La variante
|
||
// est portée par l'attribut data-theme de <html> ('' ou 'dark') ; tout le reste
|
||
// est du CSS (voir la section « Palette »). Persistée dans localStorage pour
|
||
// être appliquée sans clignotement au chargement, puis alignée sur le serveur
|
||
// par loadPrefs() — voir 02-prefs.js.
|
||
function applyTheme(id){
|
||
// Tolère les anciens identifiants (loki, gpt-dark, soft…) : seule compte
|
||
// désormais la variante, déduite du suffixe.
|
||
const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark'));
|
||
document.documentElement.setAttribute('data-theme', dark?'dark':'light');
|
||
try{ localStorage.setItem('loki-theme', dark?'dark':'light'); }catch(e){}
|
||
const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark;
|
||
}
|
||
function toggleThemeDark(){
|
||
applyTheme(document.getElementById('theme-dark').checked?'dark':'light');
|
||
savePrefs();
|
||
}
|
||
function initTheme(){
|
||
let id='light'; try{ id=localStorage.getItem('loki-theme')||'light'; }catch(e){}
|
||
applyTheme(id);
|
||
}
|
||
// ===== Affichage ============================================================
|
||
// Quatre interrupteurs indépendants. Chacun pose un attribut sur <html> ; le CSS
|
||
// fait le reste, sauf « replier les bulles » qui est un comportement (voir
|
||
// 08-chat-render).
|
||
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('loki-'+id, on?'1':'0'); }catch(e){}
|
||
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
|
||
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
|
||
if(id==='hide-side' && !on){
|
||
document.getElementById('side').classList.remove('open');
|
||
document.getElementById('backdrop').classList.remove('open');
|
||
document.body.classList.remove('drawer-open');
|
||
}
|
||
}
|
||
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
|
||
function initView(){
|
||
VIEW_OPTS.forEach(o=>{
|
||
let v=null; try{ v=localStorage.getItem('loki-'+o.id); }catch(e){}
|
||
applyView(o.id, v==='1');
|
||
});
|
||
}
|
||
// ===== Persistance côté serveur (partagée entre appareils) ==================
|
||
// L'apparence est aussi enregistrée sur la machine loki (/api/prefs) : ainsi le
|
||
// thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le
|
||
// localStorage reste utilisé pour appliquer instantanément au chargement (sans
|
||
// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité).
|
||
function savePrefs(){
|
||
let theme='light';
|
||
try{ theme=localStorage.getItem('loki-theme')||'light'; }catch(e){}
|
||
const p={theme};
|
||
VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; });
|
||
jpost('/api/prefs', p).catch(()=>{});
|
||
}
|
||
async function loadPrefs(){
|
||
try{
|
||
const p=await jget('/api/prefs');
|
||
if(p && p.ok && p.prefs){
|
||
// Un thème enregistré par une version précédente (loki, gpt-dark…) est
|
||
// normalisé par applyTheme ; on réenregistre pour purger le serveur.
|
||
if(p.prefs.theme){
|
||
applyTheme(p.prefs.theme);
|
||
if(p.prefs.theme!=='light' && p.prefs.theme!=='dark') savePrefs();
|
||
}
|
||
VIEW_OPTS.forEach(o=>{
|
||
const v=p.prefs[o.id.replace('-','_')];
|
||
if(v!==undefined) applyView(o.id, v==='1');
|
||
});
|
||
}
|
||
}catch(e){}
|
||
}
|
||
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('loki.sys') || ''; loadSys(); restoreChat(); });
|
||
// Pastille d'état d'une section du menu (mode agent, internet, MCP, distant).
|
||
// `state` : true = actif (pilule pleine), false = inactif (contour), 'warn'/'err'
|
||
// = état signalé, null = pastille masquée. Un seul endroit décide de leur allure.
|
||
function setBadge(id, state, text){
|
||
const el = typeof id==='string' ? document.getElementById(id) : id;
|
||
if(!el) return;
|
||
el.className = 'sbadge' + (state===true ? ' on' : (state==='warn' || state==='err') ? ' '+state : '');
|
||
el.textContent = state===null || state===undefined ? '' : (text||'');
|
||
}
|
||
// Ouverture/fermeture des modales. Le display seul apparaissait d'un bloc : on
|
||
// pose la classe .show une frame APRÈS l'affichage pour que la transition CSS
|
||
// parte de son état initial (sans ce reflow forcé, le navigateur applique tout
|
||
// d'un coup et l'animation ne joue pas). À la fermeture on attend la fin de la
|
||
// transition avant de repasser en display:none.
|
||
function showModal(id){
|
||
const el = typeof id==='string' ? document.getElementById(id) : id;
|
||
if(!el) return;
|
||
el.style.display='flex';
|
||
void el.offsetHeight;
|
||
el.classList.add('show');
|
||
}
|
||
function hideModal(id){
|
||
const el = typeof id==='string' ? document.getElementById(id) : id;
|
||
if(!el) return;
|
||
el.classList.remove('show');
|
||
// Rouverte entre-temps ? On ne la cache surtout pas.
|
||
setTimeout(()=>{ if(!el.classList.contains('show')) el.style.display='none'; }, 180);
|
||
}
|
||
function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); }
|
||
// Modales natives (askConfirm/askPrompt/askAlert) — remplacent confirm()/prompt()/
|
||
// alert() par de vraies boîtes stylées. Chacune renvoie une Promise : askConfirm →
|
||
// bool, askPrompt → string|null (null si annulé), askAlert → void. Échap/clic dehors
|
||
// = annuler ; Entrée = valider (sur prompt aussi).
|
||
let _askResolver=null, _askKind='confirm', _askCheck=false;
|
||
// État de la case optionnelle de la DERNIÈRE confirmation (opts.check). Lu par
|
||
// l'appelant juste après le await — la Promise, elle, ne renvoie que oui/non.
|
||
function askChecked(){ return _askCheck; }
|
||
function askResolve(ok){
|
||
if(!_askResolver) return;
|
||
const r=_askResolver; _askResolver=null;
|
||
_askCheck = ok && document.getElementById('ask-check-input').checked;
|
||
hideModal('ask-modal');
|
||
document.removeEventListener('keydown', _askKey, true);
|
||
if(_askKind==='prompt') r(ok ? document.getElementById('ask-input').value : null);
|
||
else r(ok);
|
||
}
|
||
function _askKey(e){
|
||
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); askResolve(false); }
|
||
else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); }
|
||
}
|
||
function _openAsk(kind, message, opts){
|
||
// Compat 2 conventions : (message, opts) [UI loki] ET l'objet unique
|
||
// {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html,
|
||
// où cette fonction remplace le window.askConfirm du bootstrap boîte noire).
|
||
if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; }
|
||
opts=opts||{};
|
||
// Une modale déjà ouverte ne doit pas être écrasée en silence : son appelant
|
||
// attend une réponse, et l'écraser laissait sa promesse pendante POUR
|
||
// TOUJOURS (requête suspendue, bouton figé). On l'annule proprement d'abord.
|
||
if(_askResolver){ const prev=_askResolver; _askResolver=null; prev(_askKind==='prompt' ? null : false); }
|
||
_askKind=kind;
|
||
document.getElementById('ask-title').textContent = opts.title || (kind==='alert'?'Info':kind==='prompt'?'Saisie':'Confirmation');
|
||
document.getElementById('ask-msg').textContent = message||'';
|
||
const inp=document.getElementById('ask-input');
|
||
if(kind==='prompt'){ inp.style.display=''; inp.value=opts.default||''; inp.placeholder=opts.placeholder||''; }
|
||
else inp.style.display='none';
|
||
// Case facultative (ex. « supprimer aussi le fichier .gguf »).
|
||
const chk=document.getElementById('ask-check');
|
||
chk.style.display = opts.check ? 'inline-flex' : 'none';
|
||
document.getElementById('ask-check-label').textContent = opts.check || '';
|
||
document.getElementById('ask-check-input').checked = false;
|
||
_askCheck=false;
|
||
const cancel=document.getElementById('ask-cancel'), ok=document.getElementById('ask-ok');
|
||
cancel.style.display = kind==='alert' ? 'none' : '';
|
||
cancel.textContent = opts.cancelText || 'Annuler';
|
||
ok.textContent = opts.okText || (kind==='alert'?'OK':kind==='prompt'?'Valider':'Confirmer');
|
||
ok.classList.toggle('danger', !!opts.danger);
|
||
showModal('ask-modal');
|
||
document.addEventListener('keydown', _askKey, true);
|
||
setTimeout(()=>{ const f = kind==='prompt'?inp:ok; f.focus(); if(kind==='prompt') inp.select(); }, 30);
|
||
return new Promise(res=>{ _askResolver=res; });
|
||
}
|
||
function askConfirm(message,opts){ return _openAsk('confirm',message,opts); }
|
||
function askPrompt(message,opts){ return _openAsk('prompt',message,opts); }
|
||
function askAlert(message,opts){ return _openAsk('alert',message,opts); }
|
||
// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*.
|
||
// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage.
|
||
let TOKEN = localStorage.getItem('loki.key') || '';
|
||
function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; }
|
||
// Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link.
|
||
// Garde les chemins absolus (/api/…) corrects derrière le tunnel.
|
||
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
|
||
// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le
|
||
// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint
|
||
// dès qu'on laisse traîner plusieurs onglets Loki) reste suspendue POUR TOUJOURS :
|
||
// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher.
|
||
// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal
|
||
// et ne sont donc jamais concernés.
|
||
const API_TIMEOUT_MS = 30000;
|
||
async function jfetch(u, opts){
|
||
opts = opts || {};
|
||
opts.headers = authHeaders(opts.headers);
|
||
if(u.charAt(0) === '/') u = API_BASE + u;
|
||
let timer=null;
|
||
if(!opts.signal){
|
||
const ac=new AbortController();
|
||
opts.signal=ac.signal;
|
||
timer=setTimeout(()=>ac.abort(), API_TIMEOUT_MS);
|
||
}
|
||
let r;
|
||
try{ r = await fetch(u, opts); }
|
||
catch(e){
|
||
if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Loki ouverts ?)'); }
|
||
throw e;
|
||
}
|
||
finally{ if(timer) clearTimeout(timer); }
|
||
if(r.status === 401){
|
||
const k = await askKeyOnce();
|
||
if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
|
||
}
|
||
return r;
|
||
}
|
||
// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels.
|
||
//
|
||
// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le
|
||
// rafraîchissement périodique en relance autant. Sans ce partage, CHAQUE 401
|
||
// rouvrait la modale, laquelle vide son champ à l'ouverture : impossible de
|
||
// taper la clé, les caractères disparaissaient au fur et à mesure. Les appels
|
||
// concurrents attendent désormais la même promesse, et repartent tous avec la
|
||
// clé saisie une seule fois.
|
||
let _keyAsk = null;
|
||
function askKeyOnce(){
|
||
if(!_keyAsk){
|
||
_keyAsk = askPrompt('Clé de pilotage Loki requise :', {title:'Authentification', placeholder:'clé…'})
|
||
.then(k => {
|
||
_keyAsk = null;
|
||
if(k){ TOKEN = k.trim(); localStorage.setItem('loki.key', TOKEN); }
|
||
return k;
|
||
}, e => { _keyAsk = null; throw e; });
|
||
}
|
||
return _keyAsk;
|
||
}
|
||
async function jget(u){ const r=await jfetch(u); return r.json(); }
|
||
async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); }
|
||
async function loadStatus(){
|
||
const s=await jget('/api/status');
|
||
const el=document.getElementById('status-svc');
|
||
// Trois états : service coupé (err) · service actif mais modèle pas encore
|
||
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
|
||
// Pastille COURTE et honnête. Le détail (cause exacte) va dans #model-err
|
||
// dessous : la pastille ne doit pas affirmer « incompatible » quand l'échec
|
||
// peut être tout autre chose.
|
||
let cls='err', txt='arrêté';
|
||
if(s.active && s.health){ cls='ok'; txt='prêt'; }
|
||
else if(s.load_error){ cls='err'; txt='erreur'; }
|
||
else if(s.active){ cls='loading'; txt='chargement…'; }
|
||
el.className='statuspill '+cls;
|
||
el.innerHTML='<span class="dot"></span>'+txt;
|
||
MODEL_READY = !!(s.active && s.health);
|
||
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
|
||
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
|
||
STATUS_SEEN = true;
|
||
if(typeof syncSendBtn==='function') syncSendBtn();
|
||
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
|
||
if(s.version){
|
||
document.getElementById('ver').textContent='v'+s.version;
|
||
}
|
||
// Avertissement de lancement (App Translocation macOS) : rare, mais il explique
|
||
// des symptomes tres deroutants, donc on l'affiche en permanence tant qu'il dure.
|
||
const wb=document.getElementById('app-warn');
|
||
if(wb){
|
||
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
|
||
else { wb.style.display='none'; }
|
||
}
|
||
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
|
||
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
|
||
const me=document.getElementById('model-err');
|
||
if(me){
|
||
if(s.load_error){ me.textContent='⚠ '+s.load_error; me.style.display=''; }
|
||
else { me.style.display='none'; }
|
||
}
|
||
}
|
||
// Journal du moteur — section repliable comme les autres. La pastille d'état
|
||
// reste un raccourci ; le chargement du journal est déclenché par l'événement
|
||
// `toggle` du <details>, qu'on l'ouvre par la pastille ou par son chevron.
|
||
function toggleSvcLog(){
|
||
const box=document.getElementById('svc-log-box');
|
||
if(!box) return;
|
||
box.open = !box.open;
|
||
}
|
||
async function loadSvcLog(){
|
||
const el=document.getElementById('svc-log');
|
||
if(!el) return;
|
||
el.textContent='chargement du journal…';
|
||
try{
|
||
const r=await jget('/api/service/log?n=120');
|
||
el.textContent = (r && r.log && r.log.trim()) ? r.log : 'journal vide — le moteur n\'a encore rien écrit.';
|
||
el.scrollTop = el.scrollHeight;
|
||
}catch(e){ el.textContent='journal indisponible : '+e; }
|
||
}
|
||
async function checkUpdate(){
|
||
const b=document.getElementById('upd-check'), msg=document.getElementById('upd-msg');
|
||
b.disabled=true; msg.textContent='Vérification…';
|
||
try{
|
||
const r=await jget('/api/update');
|
||
// En conteneur, la mise à jour passe par l'image : on l'explique au lieu de
|
||
// proposer un remplacement de binaire impossible ici.
|
||
if(r.note){ msg.textContent=r.note; }
|
||
else if(r.error){ msg.textContent='Erreur : '+r.error; }
|
||
else if(r.available){
|
||
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
|
||
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
|
||
msg.appendChild(btn);
|
||
} else { msg.textContent='Loki est à jour ✓'; }
|
||
}catch(e){ msg.textContent='Erreur réseau'; }
|
||
b.disabled=false;
|
||
}
|
||
// Emplacements — affichés avec le journal du moteur : c'est le panneau qu'on
|
||
// ouvre quand on cherche à comprendre l'état de son installation.
|
||
async function showPaths(){
|
||
const el=document.getElementById('paths-msg');
|
||
if(!el) return;
|
||
el.textContent='…';
|
||
try{
|
||
const p=await jget('/api/paths');
|
||
const rows=[['Données',p.home],['Base (config, préférences, conversation)',p.database],['Modèles',p.models],['Presets',p.presets],['Mémoire',p.memory],['Fichiers créés par l\'IA',p.workspace],['Moteur llama.cpp',p.backends],['Programme',p.exe]];
|
||
el.innerHTML=rows.map(r=>'<div style="margin-bottom:4px">'+r[0]+'<br><code style="word-break:break-all">'+escHtml(r[1]||'')+'</code></div>').join('');
|
||
}catch(e){ el.textContent='Erreur'; }
|
||
}
|
||
async function applyUpdate(){
|
||
const msg=document.getElementById('upd-msg');
|
||
msg.textContent='Téléchargement et installation…';
|
||
try{
|
||
// Signal dédié : le timeout par défaut (30 s) coupe le téléchargement du
|
||
// binaire sur une connexion lente et fait croire à un échec alors que la
|
||
// mise à jour aboutit côté serveur.
|
||
const ac=new AbortController(); const t=setTimeout(()=>ac.abort(), 10*60*1000);
|
||
let r;
|
||
try{ r=await (await jfetch('/api/update/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',signal:ac.signal})).json(); }
|
||
finally{ clearTimeout(t); }
|
||
if(r.ok){
|
||
msg.innerHTML='✓ Installé en <b>v'+r.version+'</b>.<br>'+(r.restart||'');
|
||
// Redémarrage auto du service côté serveur : le flux va se couper puis
|
||
// reconnecter tout seul (connectStream boucle). On rafraîchit l'état après.
|
||
if(r.restarting){ toast('mise à jour appliquée — reconnexion…'); setTimeout(loadAll, 6000); }
|
||
}
|
||
else { msg.textContent='Échec : '+(r.error||'inconnu'); }
|
||
}catch(e){ msg.textContent='Erreur pendant la mise à jour (réessaie).'; }
|
||
}
|
||
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
|
||
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
|
||
let CTX_MAX=0, CTX_USED=0, MODEL_READY=false;
|
||
// STATUS_SEEN : /api/status a répondu au moins une fois. Avant ça (ou s'il ne
|
||
// répond pas), on ne verrouille RIEN — mieux vaut un envoi qui échoue qu'un chat
|
||
// bloqué par un état inconnu.
|
||
let STATUS_SEEN=false;
|
||
function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
|
||
function updateCtxMeter(){
|
||
if(!CTX_MAX) return;
|
||
const pct=Math.min(100, Math.round(CTX_USED*100/CTX_MAX));
|
||
const fill=document.getElementById('ctx-fill');
|
||
fill.style.width=pct+'%';
|
||
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
|
||
// Les chiffres SANS le mot « contexte » : la jauge juste en dessous dit déjà de
|
||
// quoi on parle, et le pied de carte est étroit. Le libellé complet reste en
|
||
// infobulle.
|
||
const ct=document.getElementById('ctx-text');
|
||
ct.textContent=CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
|
||
ct.title='contexte utilisé';
|
||
// Bouton de compaction MANUELLE : visible dès la moitié du contexte pour qu'on
|
||
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
|
||
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
|
||
}
|
||
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;
|
||
// En conteneur, le moteur vient de l'image : le dire, plutôt que de le
|
||
// ranger dans « personnalisé » qui laisse croire à un bricolage.
|
||
if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image';
|
||
else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
||
else if(lc && lc.in_use) v='llama.cpp compilé';
|
||
else v='llama.cpp personnalisé : '+c.BIN;
|
||
rows.push(row('MOTEUR', v, c.BIN));
|
||
}
|
||
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
|
||
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
|
||
rows.push(row(k, v));
|
||
});
|
||
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
|
||
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
|
||
if(m) rows.push(row('N-CPU-MOE', m[1]));
|
||
document.getElementById('cfg').innerHTML = rows.join('');
|
||
}
|
||
// --- Réserve de hauteur des blocs peuplés par le réseau ---------------------
|
||
// Ces blocs sont vides (ou réduits à « … ») tant que le serveur n'a pas répondu,
|
||
// puis prennent leur vraie taille : tout le menu sautait alors d'un cran, ce qui
|
||
// se voyait surtout sur les sections du bas (« Moteur »). On mémorise leur
|
||
// hauteur d'une session à l'autre et on la réserve au démarrage ; la réserve
|
||
// tombe dès que le vrai contenu est là (elle ne fait que TENIR la place, elle
|
||
// n'affiche jamais rien de faux).
|
||
const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list'];
|
||
function reserveHeights(){
|
||
HRESERVE.forEach(id=>{
|
||
try{
|
||
const h=parseInt(localStorage.getItem('loki-h-'+id)||'0',10);
|
||
const el=document.getElementById(id);
|
||
if(el && h>0) el.style.minHeight=h+'px';
|
||
}catch(e){}
|
||
});
|
||
}
|
||
function releaseHeights(){
|
||
HRESERVE.forEach(id=>{
|
||
const el=document.getElementById(id); if(!el) return;
|
||
el.style.minHeight='';
|
||
try{ localStorage.setItem('loki-h-'+id, String(el.offsetHeight||0)); }catch(e){}
|
||
});
|
||
}
|
||
document.addEventListener('DOMContentLoaded', reserveHeights);
|
||
// Numéro (1-based) du preset vers lequel on bascule, tant que le serveur n'a pas
|
||
// fini de redémarrer le service. Basculer prend plusieurs secondes : sans ça, la
|
||
// liste restait FIGÉE sur l'ancien actif et on ne savait pas si le clic avait pris.
|
||
// Voir switchTo() dans 07-models.js.
|
||
let pendingPreset = 0;
|
||
// Dernière sélection PEINTE. La liste est redessinée à chaque rafraîchissement
|
||
// (et il y en a beaucoup : sondage de bascule, loadAll…) ; sans ce repère, les
|
||
// animations d'entrée (barre qui glisse, puce qui apparaît) repartaient à chaque
|
||
// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la
|
||
// sélection CHANGE vraiment.
|
||
let paintedSel = '';
|
||
async function loadPresets(){
|
||
const p=await jget('/api/presets');
|
||
// Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente.
|
||
if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0;
|
||
const act = p.find(x=>x.active);
|
||
// Build via DOM (not string concat) so preset names can contain anything —
|
||
// spaces, accents, quotes, < > & — without breaking markup or handlers.
|
||
const sp = document.getElementById('status-preset');
|
||
sp.textContent = act ? act.name : '';
|
||
sp.title = act ? 'preset actif' : '';
|
||
const cont=document.getElementById('presets');
|
||
cont.innerHTML='';
|
||
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
|
||
// Signature de la sélection : quel preset est actif, et vers lequel on bascule.
|
||
const sel = (act?act.id:'')+'|'+pendingPreset;
|
||
const moved = sel !== paintedSel; paintedSel = sel;
|
||
p.forEach((x,i)=>{
|
||
const row=document.createElement('div');
|
||
const pend = !x.active && pendingPreset===i+1;
|
||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
||
row.onclick=()=>switchTo(i+1, x.name);
|
||
const info=document.createElement('div'); info.className='preset-info';
|
||
const nm=document.createElement('div'); nm.className='preset-name';
|
||
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
||
// est dessiné par la police du système — sur Windows il sortait plus petit et
|
||
// plus bas que sur macOS. Un disque CSS a la même taille et la même assiette
|
||
// partout.
|
||
// Puce réservée à l'actif POUR DE BON : pendant la bascule, seule la barre
|
||
// orange parle ; la puce apparaît quand la barre passe au blanc.
|
||
if(x.active){ const d=document.createElement('i'); d.className='preset-dot'; nm.appendChild(d); }
|
||
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
|
||
// Second row: quant tag + bench perf, so the title row stays full-width.
|
||
const meta=document.createElement('div'); meta.className='preset-meta';
|
||
if(x.quant){
|
||
const q=document.createElement('span'); q.className='qtag';
|
||
q.textContent=x.quant; q.title='quantization';
|
||
meta.appendChild(q);
|
||
}
|
||
if(x.reasoning){
|
||
const rt=document.createElement('span'); rt.className='rtag';
|
||
rt.title='raisonnement actif ('+x.reasoning+')';
|
||
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
|
||
rt.appendChild(document.createTextNode(x.reasoning));
|
||
meta.appendChild(rt);
|
||
}
|
||
if(x.bench){
|
||
const bt=document.createElement('span'); bt.className='btag';
|
||
bt.title='prefill / decode — dernier bench de ce preset';
|
||
bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s';
|
||
meta.appendChild(bt);
|
||
}
|
||
info.appendChild(nm);
|
||
if(meta.children.length) info.appendChild(meta);
|
||
const edit=document.createElement('button');
|
||
// Engrenage (et non plus un crayon) : la silhouette est ronde, donc elle se
|
||
// lit comme centrée dans son coin, là où le crayon penché tirait de travers.
|
||
edit.className='preset-edit'; edit.title='réglages du preset';
|
||
edit.innerHTML='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
|
||
edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); };
|
||
row.appendChild(info); row.appendChild(edit);
|
||
cont.appendChild(row);
|
||
});
|
||
}
|
||
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
|
||
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
|
||
async function loadAgent(){
|
||
const s=await jget('/api/agent');
|
||
const on = s.enabled;
|
||
document.getElementById('agent-toggle').checked = on;
|
||
document.getElementById('compact-toggle').checked = (s.compact !== false);
|
||
setBadge('agent-badge', on, on?'actif':'inactif');
|
||
document.getElementById('brand').classList.toggle('agent', on);
|
||
setAgentGate(on);
|
||
if(s.mem_mode){ document.getElementById('mem-mode').value = s.mem_mode; renderMemModeDesc(s.mem_mode); }
|
||
memPages = (s.pages || s.skills || []).slice().sort((a,b)=>a.name.localeCompare(b.name));
|
||
memShown = MEM_PAGE;
|
||
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
|
||
document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none';
|
||
renderMemList();
|
||
}
|
||
// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni
|
||
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
|
||
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
|
||
function setAgentGate(on){
|
||
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
|
||
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
|
||
});
|
||
}
|
||
// Repli/dépli de la liste des pages mémoire (fermée par défaut → gagne de la place).
|
||
function toggleMemPages(){
|
||
const body=document.getElementById('mem-pages-body');
|
||
const bar=document.getElementById('mem-pages-bar');
|
||
const open=body.hasAttribute('hidden');
|
||
if(open){ body.removeAttribute('hidden'); bar.classList.add('open'); }
|
||
else { body.setAttribute('hidden',''); bar.classList.remove('open'); }
|
||
}
|
||
// Liste mémoire scalable : recherche + rendu plafonné (les milliers de pages ne
|
||
// déroulent plus une barre géante). memShown grimpe par paliers via « voir plus ».
|
||
let memPages=[], memShown=0; const MEM_PAGE=50;
|
||
function renderMemList(){
|
||
const q=(document.getElementById('mem-search').value||'').trim().toLowerCase();
|
||
const list=document.getElementById('mem-list');
|
||
list.textContent='';
|
||
if(!memPages.length){ list.innerHTML='<div class="muted">(aucune page mémoire)</div>'; return; }
|
||
const matches = q ? memPages.filter(x=>(x.name+' '+(x.desc||'')).toLowerCase().includes(q)) : memPages;
|
||
if(!matches.length){ list.innerHTML='<div class="muted">(aucun résultat pour « '+q.replace(/[<>&]/g,'')+' »)</div>'; return; }
|
||
const shown = matches.slice(0, memShown);
|
||
shown.forEach(x=>{
|
||
const row=document.createElement('div'); row.className='preset'; row.style.fontSize='12px';
|
||
row.onclick=()=>openMem(x.name);
|
||
const span=document.createElement('span');
|
||
const b=document.createElement('b'); b.style.color='var(--text)'; b.textContent=x.name; span.appendChild(b);
|
||
if(x.desc){ const d=document.createElement('span'); d.className='muted'; d.textContent=' — '+x.desc; span.appendChild(d); }
|
||
const btn=document.createElement('button'); btn.textContent='edit'; btn.style.cssText='margin:0;padding:2px 8px;font-size:11px';
|
||
btn.onclick=e=>{ e.stopPropagation(); openMem(x.name); };
|
||
row.appendChild(span); row.appendChild(btn); list.appendChild(row);
|
||
});
|
||
if(matches.length > shown.length){
|
||
const more=document.createElement('div'); more.className='mem-more';
|
||
more.textContent='+ voir plus ('+(matches.length-shown.length)+' de plus)';
|
||
more.style.cursor='pointer';
|
||
more.onclick=()=>{ memShown+=MEM_PAGE; renderMemList(); };
|
||
list.appendChild(more);
|
||
}
|
||
}
|
||
// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire
|
||
const loadMem = loadAgent;
|
||
async function toggleAgent(){
|
||
const on=document.getElementById('agent-toggle').checked;
|
||
if(on && !await askConfirm("L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.", {title:'Activer le mode agent ?', okText:'Activer', danger:true})){ document.getElementById('agent-toggle').checked=false; return; }
|
||
await jpost('/api/agent/toggle',{on});
|
||
loadAgent();
|
||
}
|
||
async function toggleCompact(){
|
||
const on=document.getElementById('compact-toggle').checked;
|
||
await jpost('/api/agent/compact',{on});
|
||
}
|
||
// Mode mémoire (3 états) — indépendant du mode agent.
|
||
const MEM_DESC={
|
||
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
|
||
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
|
||
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
|
||
};
|
||
function renderMemModeDesc(m){ const d=document.getElementById('mem-mode-desc'); if(d) d.textContent=MEM_DESC[m]||''; }
|
||
async function setMemMode(){
|
||
const mode=document.getElementById('mem-mode').value;
|
||
const r=await jpost('/api/memory',{mode});
|
||
renderMemModeDesc(r.mode||mode);
|
||
}
|
||
// Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte.
|
||
let internetOn=false, webEngine='go';
|
||
function renderInternet(s){
|
||
internetOn = !!s.enabled;
|
||
webEngine = s.engine || 'go';
|
||
document.getElementById('internet-toggle').checked = internetOn;
|
||
const sel=document.getElementById('web-engine');
|
||
if(sel && document.activeElement!==sel) sel.value = webEngine;
|
||
// Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré.
|
||
const cf=document.getElementById('crawl-fields');
|
||
if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none';
|
||
if(document.activeElement !== document.getElementById('crawl-url'))
|
||
document.getElementById('crawl-url').value = s.url || '';
|
||
// La pastille ne redit PAS ce que l'interrupteur montre déjà (actif/inactif) :
|
||
// elle ne sert qu'à signaler l'anomalie — actif mais serveur injoignable, cas
|
||
// où les outils web ne sont pas proposés au modèle.
|
||
if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable');
|
||
else setBadge('internet-badge', null);
|
||
// Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée),
|
||
// on indique seulement si une clé est enregistrée.
|
||
const ks=document.getElementById('crawl-key-state'), kc=document.getElementById('crawl-key-clear');
|
||
if(ks){
|
||
ks.textContent = s.key_set ? ('clé enregistrée '+(s.key_hint||'')) : 'aucune clé — serveur ouvert';
|
||
if(kc) kc.style.display = s.key_set ? '' : 'none';
|
||
const ki=document.getElementById('crawl-key');
|
||
if(ki && document.activeElement!==ki) ki.value='';
|
||
if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)';
|
||
}
|
||
const st=document.getElementById('internet-status');
|
||
if(webEngine!=='crawl4ai'){ st.innerHTML='<span style="color:var(--accent)">✓</span> moteur intégré — aucune installation requise (pas de rendu JavaScript)'; }
|
||
else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
|
||
else if(s.reachable){ st.innerHTML='<span style="color:var(--accent)">✓</span> serveur joignable — outils web actifs'; }
|
||
else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; }
|
||
}
|
||
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
|
||
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
|
||
let OAI_KEY='', OAI_REVEAL=false;
|
||
async function copyText(txt, msg){
|
||
if(!txt){ toast('rien à copier'); return; }
|
||
try{ await navigator.clipboard.writeText(txt); }
|
||
catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
|
||
toast(msg||'copié');
|
||
}
|
||
function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); }
|
||
function renderApiKey(d){
|
||
OAI_KEY = d.key || '';
|
||
// 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')); }
|
||
// --- Export de la conversation ---------------------------------------------
|
||
// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de
|
||
// /api/chat/state ; 0 = conversation vide, et la fenêtre n'a alors rien à régler.
|
||
let xTurnsTotal = 0;
|
||
async function openExportModal(){
|
||
showModal('export-modal');
|
||
try{
|
||
const st = await jget('/api/chat/state');
|
||
xTurnsTotal = (st && st.turns) || 0;
|
||
}catch(e){ xTurnsTotal = 0; }
|
||
// Rien à exporter : on remplace les réglages par un message, plutôt que de
|
||
// proposer de tailler un fichier qui serait vide de toute façon.
|
||
const vide = xTurnsTotal === 0;
|
||
document.getElementById('x-empty').style.display = vide ? '' : 'none';
|
||
document.getElementById('x-body').style.display = vide ? 'none' : '';
|
||
document.getElementById('x-go').style.display = vide ? 'none' : '';
|
||
if(vide) return;
|
||
const el = document.getElementById('x-turns');
|
||
el.max = String(xTurnsTotal);
|
||
el.value = el.max; // par défaut : tout le fil
|
||
el.disabled = xTurnsTotal < 2; // un seul échange : rien à trancher
|
||
onExportFormat();
|
||
}
|
||
function closeExportModal(){ hideModal('export-modal'); }
|
||
// Le format ne change QUE le contenant : les options de contenu sont les mêmes
|
||
// des deux côtés. Il n'y a donc plus rien à montrer ou cacher ici, seulement le
|
||
// résumé à rafraîchir.
|
||
function onExportFormat(){ onExportPreview(); }
|
||
function exportFormat(){
|
||
const r = document.querySelector('input[name=x-fmt]:checked');
|
||
return r ? r.value : 'md';
|
||
}
|
||
// Construit la requête d'export à partir des cases. On n'envoie QUE ce qui
|
||
// s'écarte du défaut : l'URL reste lisible, et un export complet redevient le
|
||
// simple /api/chat/export.
|
||
function exportQuery(){
|
||
const p = new URLSearchParams();
|
||
if(exportFormat() === 'json') p.set('format','json');
|
||
const on = id => document.getElementById(id).checked;
|
||
if(!on('x-reasoning')) p.set('reasoning','0');
|
||
if(!on('x-tools')) p.set('tools','0');
|
||
if(!on('x-results')) p.set('results','0');
|
||
const t = exportTurns();
|
||
if(t > 0) p.set('turns', String(t));
|
||
const q = p.toString();
|
||
return '/api/chat/export' + (q ? '?'+q : '');
|
||
}
|
||
// Valeur de portée à envoyer : 0 = tout le fil. La butée DROITE du curseur vaut
|
||
// « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se
|
||
// périmerait à l'échange suivant.
|
||
function exportTurns(){
|
||
const v = parseInt(document.getElementById('x-turns').value, 10) || 0;
|
||
if(!xTurnsTotal || v >= xTurnsTotal) return 0;
|
||
return v;
|
||
}
|
||
function onExportPreview(){
|
||
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
|
||
const tools = document.getElementById('x-tools');
|
||
const results = document.getElementById('x-results');
|
||
results.disabled = !tools.checked;
|
||
if(!tools.checked) results.checked = false;
|
||
paintExportRange();
|
||
const off = [];
|
||
if(!document.getElementById('x-reasoning').checked) off.push('raisonnements');
|
||
if(!tools.checked) off.push('outils');
|
||
else if(!results.checked) off.push('sorties d\'outils');
|
||
document.getElementById('x-note').textContent = off.length
|
||
? 'Export sans ' + off.join(' ni ') + '.'
|
||
: '';
|
||
}
|
||
// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque
|
||
// pixel de glissement, il ne doit RIEN demander au serveur.
|
||
function paintExportRange(){
|
||
const el = document.getElementById('x-turns');
|
||
const n = parseInt(el.value, 10) || 0;
|
||
const max = Math.max(1, xTurnsTotal);
|
||
// La pastille ne parcourt pas toute la largeur : elle va de THUMB/2 à
|
||
// largeur - THUMB/2. On coupe le remplissage dans SON repère, sinon la piste
|
||
// colorée déborde à côté d'elle aux extrémités (même calcul que la barre GPU).
|
||
const frac = max > 1 ? (n - 1) / (max - 1) : 1;
|
||
const fill = document.getElementById('x-fill');
|
||
if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)';
|
||
const scope = document.getElementById('x-scope');
|
||
if(!scope) return;
|
||
scope.textContent = n >= xTurnsTotal
|
||
? 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'
|
||
: n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal;
|
||
}
|
||
async function runExport(){
|
||
const btn = document.getElementById('x-go');
|
||
btn.disabled = true;
|
||
try{ await downloadExport(exportQuery()); closeExportModal(); }
|
||
finally{ btn.disabled = false; }
|
||
}
|
||
// On passe par jfetch (et pas par un simple <a href>) pour deux raisons : la clé
|
||
// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas,
|
||
// et le chemin de base change derrière le tunnel (/u/<id>).
|
||
async function downloadExport(url){
|
||
toast('préparation de l\'export…');
|
||
try{
|
||
const r = await jfetch(url);
|
||
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
|
||
// Derrière app.ajean.link, le proxy chiffré réemballe TOUTE réponse en JSON :
|
||
// un export Markdown revenait donc comme une chaîne JSON entre guillemets,
|
||
// échappements compris. On la déballe. En local, rien à faire — le type de
|
||
// contenu n'est pas du JSON, et un export JSON est déjà à sa place.
|
||
let text = await r.text();
|
||
if((r.headers.get('Content-Type')||'').includes('json')){
|
||
try{ const v = JSON.parse(text); if(typeof v === 'string') text = v; }catch(_){}
|
||
}
|
||
const blob = new Blob([text]);
|
||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom
|
||
// déduit du format DEMANDÉ — le type renvoyé ne dit plus rien derrière le
|
||
// tunnel, où tout arrive en application/json.
|
||
const cd = r.headers.get('Content-Disposition') || '';
|
||
const m = cd.match(/filename="([^"]+)"/);
|
||
const name = m ? m[1] : 'loki-conversation.' + (url.includes('format=json') ? 'json' : 'md');
|
||
// ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le
|
||
// redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte
|
||
// du const — l'export échouerait avant même de partir.
|
||
const blobURL = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = blobURL; a.download = name;
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
|
||
// dans la foulée du clic.
|
||
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
|
||
toast('exporté : ' + name);
|
||
}catch(e){ toast('erreur : ' + e.message); }
|
||
}
|
||
// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------
|
||
// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le
|
||
// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le
|
||
// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part.
|
||
function renderNetwork(st){
|
||
if(!st) return;
|
||
const cb = document.getElementById('lan-toggle');
|
||
if(cb) cb.checked = !!st.exposed;
|
||
const warn = document.getElementById('lan-warn');
|
||
if(!warn) return;
|
||
if(!st.exposed){
|
||
warn.style.display = '';
|
||
warn.innerHTML = '<div class="muted" style="margin:0">Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.</div>';
|
||
return;
|
||
}
|
||
// Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute
|
||
// partout » et pourtant rien ne passe). On donne la commande à coller.
|
||
if(st.hint){
|
||
warn.style.display = '';
|
||
// --err et non --warn : la palette est volontairement monochrome et --warn y
|
||
// est un gris, illisible comme alerte. Ici il y a une vraie action à faire.
|
||
warn.innerHTML = '<div style="margin:0;color:var(--err)">⚠ '
|
||
+ escHtml(st.hint).replace(/\n/g,'<br>') + '</div>';
|
||
return;
|
||
}
|
||
warn.style.display = 'none';
|
||
}
|
||
async function loadNetwork(){
|
||
try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){}
|
||
}
|
||
async function toggleLAN(){
|
||
const cb = document.getElementById('lan-toggle');
|
||
const on = cb.checked;
|
||
const r = await jpost('/api/network', {exposed:on});
|
||
if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; }
|
||
renderNetwork(r.status);
|
||
// llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur
|
||
// affiche un état que le moteur en cours ne respecte pas encore.
|
||
if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).',
|
||
{title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){
|
||
await act('restart'); // même chemin que les boutons du panneau Moteur
|
||
}
|
||
loadApiKey();
|
||
}
|
||
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();
|
||
// Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a
|
||
// besoin d'aucun réglage : on active directement.
|
||
if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
|
||
renderInternet(await jpost('/api/internet',{enabled:on, url}));
|
||
}
|
||
async function saveWebEngine(){
|
||
const engine=document.getElementById('web-engine').value;
|
||
renderInternet(await jpost('/api/internet',{engine}));
|
||
toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné');
|
||
}
|
||
async function saveCrawlUrl(){
|
||
const url=document.getElementById('crawl-url').value.trim();
|
||
renderInternet(await jpost('/api/internet',{url}));
|
||
toast(url ? 'serveur Crawl4AI enregistré' : 'serveur Crawl4AI retiré');
|
||
}
|
||
async function saveCrawlKey(){
|
||
const el=document.getElementById('crawl-key'), key=el.value.trim();
|
||
if(!key){ toast('saisis une clé (ou « retirer » pour l\'enlever)'); return; }
|
||
renderInternet(await jpost('/api/internet',{key}));
|
||
toast('clé enregistrée');
|
||
}
|
||
async function clearCrawlKey(){
|
||
if(!await askConfirm('Retirer la clé du serveur Crawl4AI ?')) return;
|
||
document.getElementById('crawl-key').value='';
|
||
renderInternet(await jpost('/api/internet',{key:''}));
|
||
toast('clé retirée');
|
||
}
|
||
async function loadAll(){
|
||
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
|
||
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
|
||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
|
||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||
}
|
||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||
function openBenchModal(){ showModal('bench-modal'); }
|
||
function closeBenchModal(){ hideModal('bench-modal'); }
|
||
async function runBenchUI(){
|
||
const btn = document.getElementById('btn-bench');
|
||
const rerun = document.getElementById('bench-rerun');
|
||
const body = document.getElementById('bench-body');
|
||
openBenchModal();
|
||
btn.disabled = true; btn.textContent = '⏳ bench…';
|
||
rerun.disabled = true;
|
||
body.innerHTML =
|
||
'<div style="text-align:center;padding:20px 0">' +
|
||
'<div style="font-size:24px;animation:spin 1s linear infinite;display:inline-block">⏳</div>' +
|
||
'<div class="muted" style="margin-top:8px">prompt 2000 tok + 300 decode<br>~10 secondes…</div>' +
|
||
'</div>';
|
||
try{
|
||
const r = await jget('/api/bench');
|
||
if(!r.ok){
|
||
body.innerHTML = '<div style="color:var(--err);text-align:center">erreur: '+r.error+'</div>';
|
||
return;
|
||
}
|
||
const x = r.result;
|
||
body.innerHTML =
|
||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
|
||
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
|
||
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Prefill</div>' +
|
||
'<div style="font-size:26px;color:var(--accent);font-weight:600;margin:6px 0">'+x.prompt_per_second.toFixed(0)+'</div>' +
|
||
'<div class="muted">tok/s</div>' +
|
||
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s</div>' +
|
||
'</div>' +
|
||
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
|
||
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Decode</div>' +
|
||
'<div style="font-size:26px;color:var(--ok);font-weight:600;margin:6px 0">'+x.predicted_per_second.toFixed(1)+'</div>' +
|
||
'<div class="muted">tok/s</div>' +
|
||
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>';
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'bench';
|
||
rerun.disabled = false;
|
||
loadPresets();
|
||
}
|
||
}
|
||
async function switchTo(n,name){
|
||
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
|
||
toast('switching…');
|
||
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
|
||
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
|
||
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
|
||
pendingPreset = n; loadPresets();
|
||
const r=await jpost('/api/switch',{n:n});
|
||
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
|
||
toast('switched');
|
||
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
|
||
// l'écrit AVANT de répondre (et relance le service en arrière-plan, voir
|
||
// handleSwitch), donc un rafraîchissement immédiat suffit — la barre passe au
|
||
// blanc tout de suite. Le chargement du modèle par llama-server continue
|
||
// derrière ; il ne conditionne pas la sélection.
|
||
try{ await loadPresets(); }catch(e){}
|
||
// Filet : si l'empreinte n'a pas encore basculé (bascule lente côté disque), on
|
||
// reste en attente le temps qu'il faut, sans dépasser ~60 s.
|
||
for(let i=0; i<40 && pendingPreset===n; i++){
|
||
await new Promise(r=>setTimeout(r,1500));
|
||
try{ await loadPresets(); }catch(e){}
|
||
}
|
||
pendingPreset = 0;
|
||
loadAll();
|
||
}
|
||
// editingKey = the identifier of the item being edited: a preset id (filename)
|
||
// or a skill name. Empty string = creating a new item.
|
||
let editingKey = '', editingKind = 'preset';
|
||
const KINDS = {
|
||
// presets are keyed by `id` (filename) so several can share a display name;
|
||
// skills keep name-as-identity (param 'name').
|
||
// newLabel / nameHint : « Nouveau Page » et « Nom du preset » dans l'éditeur de
|
||
// mémoire venaient d'un libellé unique décliné mécaniquement.
|
||
preset: {label:'', newLabel:'Nouveau preset', nameHint:'Nom du preset',
|
||
param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()},
|
||
mem: {label:'Page', newLabel:'Nouvelle page', nameHint:'Nom de la page',
|
||
param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()},
|
||
};
|
||
// ⚠️ La modale s'ouvre AVANT d'aller chercher quoi que ce soit. Elle attendait
|
||
// auparavant la fin de 3 requêtes (contenu + backends + modèles disponibles) :
|
||
// en accès distant, ça faisait un clic sans réaction pendant une seconde, comme
|
||
// si le bouton était mort. On affiche la coquille tout de suite, puis on la
|
||
// remplit. `openSeq` protège du cas « deux ouvertures coup sur coup » : une
|
||
// réponse en retard ne doit jamais écraser la modale ouverte après elle.
|
||
// Pendant ce remplissage la modale est en `.loading` : un rond tourne à la place
|
||
// du formulaire, qui n'est révélé qu'une fois TOUT en place (contenu, réglages,
|
||
// backends, GPU) — sinon on voyait les champs se peupler un par un.
|
||
let openSeq = 0;
|
||
async function openItem(kind, key){
|
||
const K = KINDS[kind];
|
||
const seq = ++openSeq;
|
||
editingKind = kind; editingKey = key || '';
|
||
document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + key : key) : K.newLabel;
|
||
document.getElementById('m-name').value = key || '';
|
||
document.getElementById('m-name').placeholder = K.nameHint;
|
||
document.getElementById('m-content').value = '';
|
||
document.getElementById('m-del').style.display = key ? 'inline-flex' : 'none';
|
||
// Model picker is preset-only: it edits the MODEL= line of the preset.
|
||
const modelRow = document.getElementById('m-model-row');
|
||
const settingsRow = document.getElementById('m-settings-row');
|
||
const 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();
|
||
resetHfUI();
|
||
// Preset : la config brute est une ligne repliable, fermée par défaut.
|
||
rawHead.textContent = 'Configuration';
|
||
rawToggle.style.display = '';
|
||
rawBody.style.display = 'none';
|
||
rawCaret.classList.remove('open');
|
||
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
|
||
document.getElementById('m-dir-path').value = '';
|
||
setModelDirsOpen(false);
|
||
} else {
|
||
modelRow.style.display = 'none';
|
||
settingsRow.style.display = 'none';
|
||
// Page mémoire : le contenu EST le champ principal — affiché en clair.
|
||
rawHead.textContent = 'Contenu';
|
||
rawToggle.style.display = 'none';
|
||
rawBody.style.display = '';
|
||
}
|
||
const modalEl=document.getElementById('modal');
|
||
modalEl.classList.add('loading');
|
||
showModal('modal');
|
||
// Le voile s'arrête au bas de l'en-tête : on lui passe sa hauteur réelle. ⚠️ À
|
||
// mesurer APRÈS l'affichage — un élément en display:none mesure 0.
|
||
const mHead=modalEl.querySelector('.modal-head');
|
||
if(mHead) modalEl.querySelector('.modal-card').style.setProperty('--mh', mHead.offsetHeight+'px');
|
||
// ⚠️ APRÈS showModal, jamais avant : le corps de la modale est un conteneur
|
||
// défilant RÉUTILISÉ d'une ouverture à l'autre, et écrire scrollTop sur un
|
||
// élément en display:none ne fait RIEN (il n'a pas de boîte de rendu) — le
|
||
// navigateur restaurait donc l'ancienne position à l'affichage, et on rouvrait
|
||
// la fiche au milieu. On le refait après le remplissage, la hauteur ayant changé.
|
||
const topPeBody=()=>{ const b=document.querySelector('#modal .pe-body'); if(b) b.scrollTop=0; };
|
||
topPeBody();
|
||
// --- Remplissage, une fois la modale à l'écran -----------------------------
|
||
const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||''));
|
||
const d = await r.json();
|
||
if(seq !== openSeq) return; // une autre ouverture a pris la main
|
||
const display = d.name || key || '';
|
||
document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + display : display) : K.newLabel;
|
||
document.getElementById('m-name').value = display;
|
||
document.getElementById('m-content').value = d.content || '';
|
||
if(kind === 'preset'){
|
||
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
|
||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||
populateSettings();
|
||
attachDownload(); // téléchargement encore en cours côté serveur ?
|
||
await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||
if(seq !== openSeq) return;
|
||
}
|
||
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
|
||
// les transitions deux frames plus tard, une fois le nouvel état peint.
|
||
modalEl.classList.add('no-anim');
|
||
modalEl.classList.remove('loading');
|
||
topPeBody();
|
||
requestAnimationFrame(()=>requestAnimationFrame(()=>modalEl.classList.remove('no-anim')));
|
||
}
|
||
|
||
// Pretty-print bytes — handy for the dropdown options.
|
||
function fmtSize(b){
|
||
if(b > 1e9) return (b/1e9).toFixed(1)+' GB';
|
||
if(b > 1e6) return (b/1e6).toFixed(0)+' MB';
|
||
if(b > 1e3) return (b/1e3).toFixed(0)+' KB';
|
||
return b+' B';
|
||
}
|
||
// Retire les guillemets ENTOURANTS d'une valeur, et seulement eux. Les anciens
|
||
// motifs `"?([^"\n]*)"?` s'arrêtaient au premier guillemet INTERNE : une valeur
|
||
// comme `--chat-template-file "/etc/loki/tpl.jinja"` était lue tronquée, puis
|
||
// réécrite avec des guillemets non appariés (issue #17).
|
||
function unquoteVal(v){
|
||
v = String(v==null?'':v).trim();
|
||
const q = v[0];
|
||
if(v.length >= 2 && (q === '"' || q === "'") && v[v.length-1] === q) return v.slice(1,-1);
|
||
return v;
|
||
}
|
||
// Lit la valeur d'une clé KEY= dans un texte au format .env.
|
||
function readEnvKey(txt, key){
|
||
const m = String(txt).match(new RegExp('^[ \\t]*'+key+'[ \\t]*=(.*)$','m'));
|
||
return m ? unquoteVal(m[1]) : '';
|
||
}
|
||
// Read the current MODEL= value out of the textarea, handling quotes / spaces.
|
||
function currentModelInTextarea(){
|
||
return readEnvKey(document.getElementById('m-content').value, 'MODEL');
|
||
}
|
||
// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers :
|
||
// on ne contrôle pas ce que l'utilisateur y met).
|
||
function escHtml(s){
|
||
return String(s==null?'':s).replace(/[&<>"']/g, c =>
|
||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
// Compare deux chemins/noms de modèle en neutralisant séparateurs et casse.
|
||
function samePath(a, b){
|
||
const n = x => String(x||'').trim().replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||
return !!a && !!b && n(a) === n(b);
|
||
}
|
||
function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); }
|
||
async function populateModelPicker(){
|
||
const sel = document.getElementById('m-model');
|
||
const list = await jget('/api/models');
|
||
const cur = currentModelInTextarea();
|
||
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
|
||
// regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre).
|
||
const groups = [];
|
||
for(const m of (list||[])){
|
||
let g = groups.find(x => x.dir === m.dir);
|
||
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
|
||
g.items.push(m);
|
||
}
|
||
let html = '<option value="">— choisir un modèle —</option>';
|
||
let matched = false;
|
||
for(const g of groups){
|
||
let opts = '';
|
||
for(const m of g.items){
|
||
// Le preset peut référencer le chemin complet OU le simple nom de fichier.
|
||
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
|
||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
|
||
if(on) matched = true;
|
||
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
|
||
// (les autres ne se lancent pas seules) et la taille de la famille entière.
|
||
// On le dit, et surtout on signale les tranches manquantes — sinon le
|
||
// modèle se sélectionne sans broncher puis le moteur meurt au démarrage.
|
||
let tag = '';
|
||
if(m.shards > 1) tag = ' · ' + m.shards + ' fichiers';
|
||
if(m.missing && m.missing.length) tag += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
|
||
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+tag+')</option>';
|
||
}
|
||
html += groups.length > 1
|
||
? '<optgroup label="'+escHtml(g.home ? 'dossier loki' : g.dir)+'">'+opts+'</optgroup>'
|
||
: opts;
|
||
}
|
||
// MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré :
|
||
// on déplie alors la section des dossiers, c'est là que ça se répare.
|
||
if(cur && !matched){
|
||
html += '<option value="" disabled selected>('+escHtml(cur)+' — introuvable ; ajoute son dossier ci-dessous)</option>';
|
||
}
|
||
sel.innerHTML = html;
|
||
if(cur && !matched) setModelDirsOpen(true);
|
||
}
|
||
// ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) ---
|
||
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
|
||
function setModelDirsOpen(open){
|
||
const b = document.getElementById('m-dirs-body');
|
||
const c = document.getElementById('m-dirs-caret');
|
||
if(!b) return;
|
||
b.style.display = open ? '' : 'none';
|
||
if(c) c.classList.toggle('open', open);
|
||
if(open) populateModelDirs();
|
||
}
|
||
function toggleModelDirs(){
|
||
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
|
||
}
|
||
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';
|
||
const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
|
||
info.textContent = '(' + n + free + (x.home ? ', dossier loki' : '') + ')';
|
||
row.append(p, info);
|
||
if(!x.home){
|
||
const del = document.createElement('button');
|
||
del.className = 'pe-link';
|
||
del.textContent = 'retirer';
|
||
del.style.marginLeft = '8px';
|
||
del.onclick = () => removeModelDir(x.path);
|
||
row.append(del);
|
||
}
|
||
box.append(row);
|
||
}
|
||
}
|
||
async function addModelDir(){
|
||
const inp = document.getElementById('m-dir-path');
|
||
const p = inp.value.trim();
|
||
if(!p){ toast('indique un chemin de dossier'); return; }
|
||
const r = await jpost('/api/models/dirs', {path: p, action: 'add'});
|
||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||
inp.value = '';
|
||
toast('dossier ajouté');
|
||
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
|
||
}
|
||
async function removeModelDir(p){
|
||
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
|
||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||
toast('dossier retiré');
|
||
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
|
||
}
|
||
function onPickModel(){
|
||
const val = document.getElementById('m-model').value;
|
||
if(!val) return;
|
||
const ta = document.getElementById('m-content');
|
||
if(/^\s*MODEL\s*=.*$/m.test(ta.value)){
|
||
ta.value = ta.value.replace(/^\s*MODEL\s*=.*$/m, 'MODEL="'+val+'"');
|
||
} else {
|
||
// No MODEL= line yet — prepend so it's visible at the top.
|
||
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
||
}
|
||
toast('MODEL='+val);
|
||
}
|
||
|
||
// --- Vision (projecteur multimodal --mmproj) --------------------------------
|
||
// Le projecteur est un .gguf séparé du modèle : il apparaît donc dans la même
|
||
// liste que les modèles (/api/models). On le range dans sa PROPRE clé MMPROJ,
|
||
// que backend_serve.go traduit en --mmproj — plutôt que dans EXTRA_ARGS, pour
|
||
// que le chemin soit résolu comme celui du modèle (nom simple = cherché dans
|
||
// les dossiers déclarés).
|
||
function currentMmprojInTextarea(){
|
||
// Clé dédiée d'abord ; à défaut, un --mmproj posé à la main dans EXTRA_ARGS
|
||
// (anciens presets), pour que le champ montre la vision déjà configurée.
|
||
return readEnvKey(document.getElementById('m-content').value, 'MMPROJ')
|
||
|| eaGetValued('--mmproj');
|
||
}
|
||
function onPickMmproj(){
|
||
// On retire un éventuel --mmproj d'EXTRA_ARGS pour ne pas le charger deux fois,
|
||
// et on centralise le choix dans la clé MMPROJ.
|
||
eaSetValued('--mmproj', '');
|
||
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
|
||
}
|
||
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
|
||
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
|
||
// mêler les modèles de plusieurs Go n'aiderait pas à choisir des yeux.
|
||
function isMmprojName(name){ return /mmproj/i.test(String(name||'')); }
|
||
async function populateMmproj(){
|
||
const sel = document.getElementById('m-mmproj');
|
||
if(!sel) return;
|
||
const list = await jget('/api/models');
|
||
const cur = currentMmprojInTextarea();
|
||
const items = (list||[]).filter(m => isMmprojName(m.name));
|
||
let html = '<option value="">— aucune —</option>';
|
||
let matched = false;
|
||
for(const m of items){
|
||
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
|
||
samePath(baseName(cur), m.name) ? ' selected' : '';
|
||
if(on) matched = true;
|
||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||
}
|
||
// MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors
|
||
// convention) : on le garde affiché plutôt que de l'effacer silencieusement.
|
||
if(cur && !matched){
|
||
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
|
||
}
|
||
sel.innerHTML = html;
|
||
}
|
||
|
||
// Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé)
|
||
// qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel
|
||
// backend fait tourner ce modèle — la barre latérale ne fait qu'installer.
|
||
function currentBinInTextarea(){
|
||
return readEnvKey(document.getElementById('m-content').value, 'BIN');
|
||
}
|
||
// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows).
|
||
function sameBinPath(a, b){
|
||
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||
return !!a && !!b && n(a) === n(b);
|
||
}
|
||
// Écrit (ou remplace) la ligne BIN= dans le contenu du preset.
|
||
function setBinInTextarea(val){
|
||
const ta = document.getElementById('m-content');
|
||
if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"');
|
||
else ta.value = 'BIN="'+val+'"\n' + ta.value;
|
||
}
|
||
// Le moteur précompilé s'installe dans un dossier versionné (…/llama-b10280/) :
|
||
// le BIN d'un preset écrit avant une mise à jour ne vaut plus le chemin courant,
|
||
// alors qu'il désigne bien ce moteur. On reconnaît donc l'appartenance au
|
||
// dossier, sinon tous les presets retombaient en « personnalisé » à chaque MAJ.
|
||
function underDir(p, dir){
|
||
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||
return !!p && !!dir && n(p).startsWith(n(dir)+'/');
|
||
}
|
||
let beFastPath = '', beOptPath = '', beFastDir = '', beImagePath = '';
|
||
async function populateBackend(){
|
||
// Chemins des deux moteurs gérés + liste des backends détectés (dossier loki).
|
||
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
|
||
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
|
||
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
|
||
beOptPath = lc.bin || '';
|
||
// Conteneur : le moteur vient de l'image. « Précompilé » et « Compilé »
|
||
// désignent des installations que Loki ne fait pas ici — les proposer ne
|
||
// menait qu'à « installez d'abord… ». On les remplace par « Image ».
|
||
beImagePath = lc.provided ? (lc.provided_bin || '') : '';
|
||
document.getElementById('be-lab-image').style.display = beImagePath ? '' : 'none';
|
||
document.getElementById('be-lab-fast').style.display = beImagePath ? 'none' : '';
|
||
document.getElementById('be-lab-opt').style.display = beImagePath ? 'none' : '';
|
||
document.getElementById('be-image-note').textContent = beImagePath ? 'llama.cpp officiel' : '';
|
||
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
|
||
// le dossier backends de loki (l'utilisateur peut y déposer son propre build).
|
||
const detected = await jget('/api/backends');
|
||
const sel = document.getElementById('m-backend-detected');
|
||
let html = '<option value="">— ou choisir un backend détecté —</option>';
|
||
for(const b of (detected||[])) html += '<option value="'+b.path+'">'+b.name+'</option>';
|
||
sel.innerHTML = html;
|
||
document.getElementById('be-drop-hint').textContent = lc.backends_dir
|
||
? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
|
||
// Sélectionne l'option correspondant au BIN actuel du preset.
|
||
const cur = currentBinInTextarea();
|
||
let mode = 'custom';
|
||
// Un preset sans BIN hérite du moteur global : en conteneur c'est celui de
|
||
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ».
|
||
if(beImagePath && (sameBinPath(cur, beImagePath) || !cur)) mode = 'image';
|
||
else if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
|
||
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
|
||
const radio = document.querySelector('input[name=m-be][value='+mode+']');
|
||
if(radio) radio.checked = true;
|
||
toggleBackendCustom(mode);
|
||
if(mode === 'custom') document.getElementById('m-backend-path').value = cur;
|
||
// Attendu (et non lancé dans le vide) : la liste des GPU change la hauteur du
|
||
// bloc « moteur ». Sans ce await, elle arrivait APRÈS la levée du voile de
|
||
// chargement et on voyait le bloc bouger tout seul.
|
||
await loadGpuDevices();
|
||
}
|
||
function toggleBackendCustom(mode){
|
||
document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none';
|
||
}
|
||
function onBackendMode(mode){
|
||
toggleBackendCustom(mode);
|
||
if(mode === 'image'){
|
||
if(!beImagePath){ toast('aucun moteur fourni par l\'image'); return; }
|
||
setBinInTextarea(beImagePath); toast('moteur : llama.cpp de l\'image');
|
||
loadGpuDevices();
|
||
} else if(mode === 'fast'){
|
||
if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; }
|
||
setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé');
|
||
loadGpuDevices();
|
||
} else if(mode === 'opt'){
|
||
if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; }
|
||
setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé');
|
||
loadGpuDevices();
|
||
}
|
||
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
|
||
}
|
||
function onCustomPath(){
|
||
const v = document.getElementById('m-backend-path').value.trim();
|
||
if(v){ setBinInTextarea(v); loadGpuDevices(); }
|
||
}
|
||
function onPickDetected(){
|
||
const v = document.getElementById('m-backend-detected').value;
|
||
if(!v) return;
|
||
document.getElementById('m-backend-path').value = v;
|
||
setBinInTextarea(v); toast('moteur personnalisé');
|
||
loadGpuDevices();
|
||
}
|
||
|
||
// --- Cartes graphiques (--device / --tensor-split) --------------------------
|
||
// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement :
|
||
// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que
|
||
// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles
|
||
// qui s'étalaient sur les deux cartes malgré une sélection « GPU 1 ».
|
||
// La lecture/écriture des flags passe par eaGetValued/eaSetValued (le tokenizer
|
||
// d'EXTRA_ARGS déjà utilisé par les autres réglages) : un seul parseur.
|
||
|
||
// Diamètre de la pastille du curseur, en pixels — DOIT rester synchronisé avec
|
||
// .gpu-range::-webkit-slider-thumb / ::-moz-range-thumb dans styles.css.
|
||
const GPU_THUMB = 16;
|
||
let gpuDevices = [];
|
||
const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture
|
||
|
||
// Interroge le moteur du preset (--list-devices) : les noms de device et leur
|
||
// ordre lui appartiennent, une liste issue de nvidia-smi désignerait la mauvaise
|
||
// carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la réponse n'est
|
||
// pas là, et le reste s'il y a moins de deux cartes : rien à arbitrer, et ça
|
||
// évite un encart qui surgit après coup.
|
||
// Remplit le cache sans rien peindre (appelé au chargement de la page pour le
|
||
// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt.
|
||
async function prefetchGpuDevices(bin){
|
||
if(!bin || gpuCache[bin]) return;
|
||
try{
|
||
const r = await jpost('/api/backends/devices', {bin});
|
||
if(r && r.ok) gpuCache[bin] = r.devices || [];
|
||
}catch(_){}
|
||
}
|
||
|
||
async function loadGpuDevices(){
|
||
const group = document.getElementById('m-gpu-group');
|
||
const bin = currentBinInTextarea();
|
||
if(!bin){ group.hidden = true; gpuDevices = []; return; }
|
||
if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; }
|
||
let r = {};
|
||
try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; }
|
||
// Le moteur a pu changer pendant l'appel (clic rapide) : on ne peint que si la
|
||
// réponse concerne toujours le moteur affiché.
|
||
if(currentBinInTextarea() !== bin) return;
|
||
gpuDevices = (r.ok && r.devices) ? r.devices : [];
|
||
if(r.ok) gpuCache[bin] = gpuDevices;
|
||
renderGpu();
|
||
}
|
||
|
||
function selectedGpuIds(){
|
||
const sel = eaGetValued('--device').split(',').filter(Boolean);
|
||
const known = gpuDevices.map(d => d.id);
|
||
const kept = sel.filter(id => known.includes(id));
|
||
return kept.length ? kept : known; // aucune contrainte = toutes les cartes
|
||
}
|
||
|
||
function gpuEsc(t){
|
||
return String(t).replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||
}
|
||
|
||
function renderGpu(){
|
||
const group = document.getElementById('m-gpu-group');
|
||
// Une seule carte (ou aucune) : rien à répartir, rien à choisir.
|
||
if(gpuDevices.length < 2){ group.hidden = true; return; }
|
||
const sel = selectedGpuIds();
|
||
let html = gpuDevices.map(d => {
|
||
// Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le
|
||
// modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge.
|
||
const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : '';
|
||
return '<div class="pe-row">'
|
||
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
|
||
+ '<span class="pe-sub">' + gpuEsc(d.id) + gb + '</span></span>'
|
||
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
|
||
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
|
||
+ '</div>';
|
||
}).join('');
|
||
if(sel.length > 1) html += splitHtml(sel);
|
||
document.getElementById('m-gpu-list').innerHTML = html;
|
||
document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length
|
||
? 'Toutes les cartes sont utilisées.'
|
||
: 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.';
|
||
group.hidden = false;
|
||
if(sel.length > 1) paintSplit();
|
||
}
|
||
|
||
function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; }
|
||
// Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1
|
||
// ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui
|
||
// veulent savoir ce qui part dans --device.
|
||
function gpuLabel(d){
|
||
return String((d && d.name) || (d && d.id) || '')
|
||
.replace(/^NVIDIA\s+GeForce\s+/i, '')
|
||
.replace(/^NVIDIA\s+/i, '')
|
||
.replace(/^AMD\s+(Radeon\s+)?/i, '')
|
||
.replace(/^Intel\(R\)\s+/i, '')
|
||
.trim() || (d && d.id) || '';
|
||
}
|
||
|
||
// Répartition : UNE seule barre, sur laquelle on glisse directement (le curseur
|
||
// est transparent, posé par-dessus). Trois cartes ou plus : un pourcentage par
|
||
// carte. On n'écrit --tensor-split QUE si l'utilisateur y touche : sans lui,
|
||
// llama.cpp répartit tout seul.
|
||
function splitHtml(sel){
|
||
const shares = currentShares(sel);
|
||
let inner;
|
||
if(sel.length === 2){
|
||
inner = '<div class="gpu-split">'
|
||
+ '<div class="gpu-bar" id="m-split-bar"></div>'
|
||
+ '<input type="range" class="gpu-range" id="m-split-range" min="0" max="100" step="0.5"'
|
||
+ ' value="' + (shares[0]*100).toFixed(1) + '" oninput="onSplitRange()"'
|
||
+ ' aria-label="part de ' + gpuEsc(gpuLabel(gpuById(sel[0]))) + '"></div>';
|
||
} else {
|
||
inner = '<div class="gpu-bar" id="m-split-bar"></div>'
|
||
+ '<div class="gpu-shares">' + sel.map((id, i) =>
|
||
'<label class="gpu-share"><span>' + gpuEsc(gpuLabel(gpuById(id))) + '</span>'
|
||
+ '<input type="number" min="0" max="100" step="0.5" value="' + (shares[i]*100).toFixed(1) + '"'
|
||
+ ' oninput="onSplitNumbers()"><span class="pe-unit">%</span></label>').join('') + '</div>';
|
||
}
|
||
return '<div class="pe-row">'
|
||
+ '<span class="pe-row-l">Répartition<span class="pe-sub">part des couches par carte</span></span>'
|
||
+ '<span class="pe-row-c" id="m-split-state"></span></div>'
|
||
+ '<div class="pe-row stack">' + inner
|
||
+ '<div class="gpu-legend" id="m-split-legend"></div></div>';
|
||
}
|
||
|
||
// Parts actuelles, normalisées à 1. Sans --tensor-split : proportionnelles à la
|
||
// VRAM de chaque carte, ce que llama.cpp fait de son côté — la barre montre donc
|
||
// ce qui se passe réellement plutôt qu'un partage égal trompeur.
|
||
function currentShares(sel){
|
||
const raw = eaGetValued('--tensor-split').split(',').map(parseFloat).filter(n => !isNaN(n));
|
||
let vals = (raw.length === sel.length) ? raw
|
||
: sel.map(id => gpuById(id).total_mib || 0);
|
||
// Mémoires inconnues (lecture à 0) : à défaut de proportion crédible, on
|
||
// montre un partage égal plutôt qu'une barre pleine sur une seule carte.
|
||
if(vals.every(v => !v)) vals = sel.map(() => 1);
|
||
const sum = vals.reduce((a, b) => a + b, 0) || 1;
|
||
return vals.map(v => v/sum);
|
||
}
|
||
|
||
// Repeint UNIQUEMENT la barre, la légende et l'état. Surtout PAS le bloc entier :
|
||
// remplacer le HTML pendant un glissement détruit le curseur en cours d'usage —
|
||
// c'est ce qui faisait qu'il ne répondait qu'au clic.
|
||
function paintSplit(){
|
||
const sel = selectedGpuIds();
|
||
if(sel.length < 2) return;
|
||
const shares = currentShares(sel);
|
||
const bar = document.getElementById('m-split-bar');
|
||
// La pastille du curseur ne parcourt PAS toute la largeur : elle va de
|
||
// GPU_THUMB/2 à largeur - GPU_THUMB/2, sinon elle déborderait de la piste. Une
|
||
// coupure à « s % » pile tombait donc à côté d'elle (jusqu'à ~5 px d'écart aux
|
||
// extrêmes), ce qui laissait voir la piste claire juste à droite de la
|
||
// pastille. On coupe dans le repère de la pastille : s*(largeur - THUMB) + THUMB/2.
|
||
// Le dernier segment prend le reste (flex:1) : aucun filet résiduel à droite,
|
||
// quels que soient les arrondis.
|
||
const withThumb = sel.length === 2 && !!document.getElementById('m-split-range');
|
||
if(bar) bar.innerHTML = shares.map((s, i) => {
|
||
const cls = i%2 ? 'alt' : '';
|
||
if(i === shares.length - 1) return '<span class="' + cls + '" style="flex:1"></span>';
|
||
const w = withThumb
|
||
? 'calc(' + (s*100).toFixed(3) + '% - ' + ((s - 0.5) * GPU_THUMB).toFixed(2) + 'px)'
|
||
: (s*100).toFixed(3) + '%';
|
||
return '<span class="' + cls + '" style="width:' + w + '"></span>';
|
||
}).join('');
|
||
const leg = document.getElementById('m-split-legend');
|
||
if(leg) leg.innerHTML = sel.map((id, i) =>
|
||
'<span><i class="' + (i%2 ? 'alt' : '') + '"></i>' + gpuEsc(gpuLabel(gpuById(id)))
|
||
+ ' ' + (shares[i]*100).toFixed(1).replace('.', ',') + ' %</span>').join('');
|
||
const st = document.getElementById('m-split-state');
|
||
if(st) st.innerHTML = eaGetValued('--tensor-split')
|
||
? '<button class="pe-link" onclick="resetSplit()">réinitialiser</button>'
|
||
: '<span class="pe-unit">automatique</span>';
|
||
}
|
||
function writeSplit(shares){
|
||
const sum = shares.reduce((a, b) => a + b, 0) || 1;
|
||
eaSetValued('--tensor-split', shares.map(s => (s/sum).toFixed(3)).join(','));
|
||
paintSplit();
|
||
}
|
||
function onSplitRange(){
|
||
const v = parseFloat(document.getElementById('m-split-range').value) / 100;
|
||
writeSplit([v, 1-v]);
|
||
}
|
||
function onSplitNumbers(){
|
||
const vals = [...document.querySelectorAll('.gpu-share input')].map(i => parseFloat(i.value) || 0);
|
||
if(vals.reduce((a, b) => a + b, 0) <= 0) return; // saisie en cours
|
||
writeSplit(vals);
|
||
}
|
||
function resetSplit(){
|
||
eaSetValued('--tensor-split', '');
|
||
const sel = selectedGpuIds();
|
||
const r = document.getElementById('m-split-range');
|
||
if(r && sel.length === 2) r.value = (currentShares(sel)[0]*100).toFixed(1);
|
||
const nums = [...document.querySelectorAll('.gpu-share input')];
|
||
if(nums.length){
|
||
const sh = currentShares(sel);
|
||
nums.forEach((inp, i) => { inp.value = (sh[i]*100).toFixed(1); });
|
||
}
|
||
paintSplit();
|
||
}
|
||
|
||
function onGpuPick(){
|
||
const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value);
|
||
if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; }
|
||
// Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer
|
||
// des noms de device qui changeraient avec le moteur.
|
||
eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(','));
|
||
// Une répartition écrite pour N cartes n'a plus de sens pour N-1.
|
||
if(ids.length < 2) eaSetValued('--tensor-split', '');
|
||
renderGpu();
|
||
}
|
||
|
||
// Read/write the QUANT= override line in the preset textarea.
|
||
function currentQuantInTextarea(){
|
||
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=(.*)$/mi);
|
||
return m ? unquoteVal(m[1]) : '';
|
||
}
|
||
function applyQuant(){
|
||
const val = document.getElementById('m-quant').value.trim();
|
||
const ta = document.getElementById('m-content');
|
||
const re = /^\s*#?\s*QUANT\s*=.*$/mi;
|
||
if(val){
|
||
if(re.test(ta.value)) ta.value = ta.value.replace(re, 'QUANT="'+val+'"');
|
||
else ta.value = ta.value.replace(/\s*$/,'') + '\nQUANT="'+val+'"\n';
|
||
} else {
|
||
ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n');
|
||
}
|
||
}
|
||
// ---- Réglages simples : champs de formulaire <-> lignes du fichier .env -----
|
||
// Le fichier de config (textarea m-content) reste la source de vérité : chaque
|
||
// champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT.
|
||
function cfgReadKey(key){
|
||
return readEnvKey(document.getElementById('m-content').value, key);
|
||
}
|
||
function cfgWriteKey(key, val){
|
||
const ta = document.getElementById('m-content');
|
||
val = String(val==null?'':val).trim();
|
||
const reLine = new RegExp('^[ \\t]*'+key+'[ \\t]*=.*$','m');
|
||
if(val === ''){ // clé vidée → on retire la ligne
|
||
ta.value = ta.value.replace(new RegExp('^[ \\t]*'+key+'[ \\t]*=.*\\n?','m'),'').replace(/\n{3,}/g,'\n\n');
|
||
return;
|
||
}
|
||
const line = key+'='+(/\s/.test(val) ? '"'+val+'"' : val);
|
||
if(reLine.test(ta.value)) ta.value = ta.value.replace(reLine, line);
|
||
else ta.value = ta.value.replace(/\s*$/,'') + '\n'+line+'\n';
|
||
}
|
||
// EXTRA_ARGS : liste de drapeaux passés tels quels à llama-server. On les édite
|
||
// jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans
|
||
// toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute.
|
||
function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); }
|
||
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); }
|
||
function eaHasFlag(flag){ return eaTokens().includes(flag); }
|
||
function eaToggleFlag(flag, on){
|
||
const t = eaTokens().filter(x=>x!==flag);
|
||
if(on) t.push(flag);
|
||
eaSetTokens(t);
|
||
}
|
||
function eaGetValued(flag){
|
||
const t = eaTokens(), i = t.indexOf(flag);
|
||
return (i>=0 && i+1<t.length && !t[i+1].startsWith('-')) ? t[i+1] : '';
|
||
}
|
||
function eaSetValued(flag, val){
|
||
const t = eaTokens(), i = t.indexOf(flag);
|
||
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
|
||
val = String(val||'').trim();
|
||
if(val !== '') t.push(flag, val);
|
||
eaSetTokens(t);
|
||
}
|
||
// Remplit les champs de réglage depuis le contenu courant du preset.
|
||
function populateSettings(){
|
||
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
|
||
set('s-ctx', cfgReadKey('CTX'));
|
||
set('s-ngl', cfgReadKey('NGL'));
|
||
set('s-threads', cfgReadKey('THREADS'));
|
||
set('s-batch', cfgReadKey('BATCH'));
|
||
set('s-ubatch', cfgReadKey('UBATCH'));
|
||
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
|
||
set('s-kv', cfgReadKey('KV_TYPE'));
|
||
set('s-moe', eaGetValued('--n-cpu-moe'));
|
||
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
|
||
setSpecType(eaGetValued('--spec-type'));
|
||
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
|
||
// Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur.
|
||
// « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle,
|
||
// laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement
|
||
// réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le
|
||
// sous-titre la dit. (Les presets créés ou modifiés depuis cette version
|
||
// écrivent toujours on ou off, la question ne se pose plus pour eux.)
|
||
const rz = cfgReadKey('REASONING');
|
||
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz));
|
||
const rzSub = document.getElementById('s-reasoning-sub');
|
||
if(rzSub){
|
||
rzSub.textContent = rz ? 'réflexion étape par étape'
|
||
: 'réflexion étape par étape — non précisé : le modèle décide';
|
||
}
|
||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||
chk('s-mlock', eaHasFlag('--mlock'));
|
||
chk('s-nommap', eaHasFlag('--no-mmap'));
|
||
}
|
||
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
|
||
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
|
||
// par des virgules ; une valeur composée (ou un type sorti après cette version
|
||
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
|
||
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
|
||
// la perdre.
|
||
function setSpecType(v){
|
||
const sel = document.getElementById('s-spec');
|
||
if(!sel) return;
|
||
v = String(v || '').trim();
|
||
if(v && ![...sel.options].some(o => o.value === v)){
|
||
const opt = document.createElement('option');
|
||
opt.value = v; opt.textContent = v + ' (dans la configuration)';
|
||
sel.appendChild(opt);
|
||
}
|
||
sel.value = v;
|
||
syncSpecRow();
|
||
}
|
||
// Le nombre de jetons anticipés ne veut rien dire sans type : ligne masquée, et
|
||
// flag retiré pour ne pas laisser un réglage orphelin dans le preset.
|
||
function syncSpecRow(){
|
||
const sel = document.getElementById('s-spec');
|
||
const row = document.getElementById('s-spec-n-row');
|
||
if(!sel || !row) return;
|
||
const on = !!sel.value;
|
||
row.style.display = on ? '' : 'none';
|
||
return on;
|
||
}
|
||
function onSpecType(){
|
||
const v = document.getElementById('s-spec').value;
|
||
eaSetValued('--spec-type', v);
|
||
if(!v){
|
||
eaSetValued('--spec-draft-n-max', '');
|
||
document.getElementById('s-spec-n').value = '';
|
||
}
|
||
syncSpecRow();
|
||
}
|
||
|
||
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
|
||
function toggleRaw(){
|
||
const b = document.getElementById('m-raw-body');
|
||
const c = document.getElementById('m-raw-caret');
|
||
const show = b.style.display === 'none';
|
||
b.style.display = show ? '' : 'none';
|
||
if(c) c.classList.toggle('open', show);
|
||
}
|
||
const openPreset = (id)=>openItem('preset', id);
|
||
const openMem = (n)=>openItem('mem', n);
|
||
// Fermer la modale n'annule PAS un téléchargement en cours (il vit côté
|
||
// serveur) : on arrête juste de l'interroger.
|
||
function closeModal(){ hideModal('modal'); document.getElementById('modal').classList.remove('loading'); stopDlPoll(); }
|
||
async function saveItem(){
|
||
const K = KINDS[editingKind];
|
||
const name = document.getElementById('m-name').value.trim();
|
||
const content = document.getElementById('m-content').value;
|
||
if(!name){ toast('nom requis'); return; }
|
||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||
// Skills: keyed by name, rename via `old`.
|
||
const payload = editingKind==='preset'
|
||
? {id: editingKey, name, content}
|
||
: {name, old: editingKey, content};
|
||
const r = await jpost(K.saveUrl, payload);
|
||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||
toast('enregistré'); closeModal(); K.reload();
|
||
}
|
||
async function delItem(){
|
||
if(!editingKey) return;
|
||
const K = KINDS[editingKind];
|
||
const name = document.getElementById('m-name').value.trim() || editingKey;
|
||
// Le choix « supprimer aussi le .gguf » est posé DANS la confirmation : il n'a
|
||
// de sens qu'au moment de supprimer, et il occupait le pied de l'éditeur en
|
||
// permanence. Décoché à chaque ouverture — jamais de modèle effacé parce que
|
||
// la case serait restée cochée d'une fois sur l'autre.
|
||
const msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?'
|
||
+ (editingKind==='preset' ? '\n\nLe fichier .gguf du modèle est conservé, sauf si vous cochez ci-dessous (irréversible).' : '');
|
||
const opts = {title:'Suppression', okText:'Supprimer', danger:true};
|
||
if(editingKind==='preset') opts.check = 'supprimer aussi le fichier .gguf';
|
||
if(!await askConfirm(msg, opts)) return;
|
||
const delModel = editingKind==='preset' && askChecked();
|
||
const payload = editingKind==='preset'
|
||
? {id: editingKey, deleteModel: delModel}
|
||
: {name: editingKey};
|
||
const r = await jpost(K.delUrl, payload);
|
||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||
if(delModel){
|
||
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
|
||
else if(r.modelDeleted) toast('preset + modèle supprimés');
|
||
else toast('supprimé (aucun modèle référencé)');
|
||
} else { toast('supprimé'); }
|
||
closeModal(); K.reload();
|
||
}
|
||
// Download a .gguf from Hugging Face. Le téléchargement vit côté serveur : fermer
|
||
// la modale ne l'interrompt pas, on se rebranche dessus à la réouverture
|
||
// (attachDownload) et seul « Annuler » l'arrête vraiment.
|
||
let dlPoll = null, dlName = '';
|
||
function dlEls(){
|
||
return {
|
||
btn: document.getElementById('m-hf-btn'),
|
||
prog: document.getElementById('m-hf-progress'),
|
||
bar: document.getElementById('m-hf-bar'),
|
||
cancel: document.getElementById('m-hf-cancel'),
|
||
};
|
||
}
|
||
// Arrête le poll local sans toucher au téléchargement serveur.
|
||
function stopDlPoll(){ if(dlPoll){ clearInterval(dlPoll); dlPoll=null; } }
|
||
// Remet la zone de téléchargement à zéro (réouverture de la modale).
|
||
function resetDlUI(){
|
||
const e = dlEls();
|
||
stopDlPoll(); dlName='';
|
||
e.prog.style.display='none'; e.bar.style.display='none';
|
||
e.cancel.style.display='none'; e.btn.disabled=false;
|
||
}
|
||
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
|
||
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
|
||
// de choisir, et ça évite une ligne de plus dans une modale déjà chargée.
|
||
// Même source que « Dossiers de modèles » : ajouter un disque externe là-bas le
|
||
// rend aussitôt disponible ici.
|
||
let dlDirList = [];
|
||
async function populateDlDirs(){
|
||
const sel = document.getElementById('m-hf-dir');
|
||
if(!sel) return;
|
||
let d = {};
|
||
try{ d = await jget('/api/models/dirs'); }catch(_){ return; }
|
||
dlDirList = d.dirs || [];
|
||
const prev = sel.value;
|
||
sel.innerHTML = '';
|
||
for(const x of dlDirList){
|
||
const o = document.createElement('option');
|
||
o.value = x.path;
|
||
o.textContent = x.path + (x.free >= 0 ? ' — ' + fmtSize(x.free) + ' libres' : '');
|
||
sel.append(o);
|
||
}
|
||
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
|
||
}
|
||
// File d'attente : installer un modèle avec son projecteur, c'est DEUX
|
||
// téléchargements, et le serveur n'en mène qu'un à la fois (une seule barre,
|
||
// un seul poll). On enchaîne donc à la fin de chacun plutôt que de les lancer
|
||
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
|
||
// vite et brouillent la progression affichée.
|
||
let dlQueue = [];
|
||
async function startDownload(){
|
||
const url = document.getElementById('m-hf-url').value.trim();
|
||
if(!url){ toast('colle un lien .gguf'); return; }
|
||
return startDownloadURL(url);
|
||
}
|
||
async function startDownloadURL(url){
|
||
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
|
||
const e = dlEls();
|
||
e.btn.disabled = true;
|
||
e.prog.style.display = 'block';
|
||
e.prog.textContent = 'vérification de la taille et de l’espace disque…';
|
||
e.bar.style.display = 'block';
|
||
e.bar.className = 'pe-bar indet';
|
||
e.bar.firstElementChild.style.width = '';
|
||
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
|
||
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
|
||
const p = await jpost('/api/models/download/probe', {url, dir});
|
||
if(!p.ok || !p.enough){
|
||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
|
||
e.bar.style.display = 'none'; e.btn.disabled=false;
|
||
dlQueue = []; // le reste de la file dépendait de ce fichier : on n'enchaîne pas
|
||
await populateDlDirs();
|
||
return;
|
||
}
|
||
// p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas
|
||
// les 15 Go du fichier dont le lien a été collé.
|
||
const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : '';
|
||
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '+fmtSize(p.free)+' libres';
|
||
const r = await jpost('/api/models/download', {url, dir});
|
||
if(!r.ok){
|
||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
||
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
|
||
}
|
||
watchDownload(r.filename);
|
||
}
|
||
// Annule le téléchargement en cours : le serveur coupe les connexions et
|
||
// supprime le fichier partiel.
|
||
async function cancelDownload(){
|
||
if(!dlName) return;
|
||
const e = dlEls();
|
||
e.cancel.disabled = true;
|
||
await jpost('/api/models/download/cancel', {filename: dlName});
|
||
// L'état « annulé » est confirmé par le prochain tick de poll.
|
||
}
|
||
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
|
||
function watchDownload(fname){
|
||
const e = dlEls();
|
||
dlName = fname;
|
||
stopDlPoll();
|
||
e.btn.disabled = true;
|
||
e.prog.style.display = 'block';
|
||
e.bar.style.display = 'block';
|
||
e.cancel.style.display = 'inline';
|
||
e.cancel.disabled = false;
|
||
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
|
||
const tick = async ()=>{
|
||
const list = await jget('/api/models/download/status');
|
||
const st = (list||[]).find(d=>d.filename===fname);
|
||
if(!st) return;
|
||
if(st.canceled){
|
||
e.prog.textContent = 'téléchargement annulé — '+fname;
|
||
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
|
||
}
|
||
if(st.error){
|
||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
|
||
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
|
||
}
|
||
if(st.finished){
|
||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
||
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||
stop();
|
||
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
|
||
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
|
||
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
|
||
// complet, pas le simple nom de fichier.
|
||
const pick = (id, cb)=>{
|
||
const s = document.getElementById(id);
|
||
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
|
||
if(o){ s.value = o.value; cb(); }
|
||
};
|
||
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
|
||
else pick('m-model', onPickModel);
|
||
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
|
||
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
|
||
// est déjà sélectionné.
|
||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||
return;
|
||
}
|
||
const pct = st.total>0 ? st.done*100/st.total : 0;
|
||
if(st.total>0){
|
||
e.bar.className = 'pe-bar';
|
||
e.bar.firstElementChild.style.width = Math.min(100, pct).toFixed(1)+'%';
|
||
} else {
|
||
e.bar.className = 'pe-bar indet';
|
||
}
|
||
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
|
||
let extra = '';
|
||
if(st.speed>0){
|
||
extra += ' — '+fmtSize(st.speed)+'/s';
|
||
if(st.total>0){
|
||
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
|
||
const m = Math.floor(eta/60), s = eta%60;
|
||
extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s');
|
||
}
|
||
}
|
||
// Une seule barre pour tout le modèle ; le compteur de tranches dit où on en
|
||
// est, sans quoi un téléchargement en 3 fichiers semble se figer par paliers.
|
||
const part = st.parts > 1 ? ' — fichier '+(st.part||1)+'/'+st.parts : '';
|
||
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+part+' — '+fname;
|
||
};
|
||
dlPoll = setInterval(tick, 800);
|
||
tick();
|
||
}
|
||
// À l'ouverture de la modale : se rebrancher sur un téléchargement encore en
|
||
// cours côté serveur (modale fermée entre-temps, page rechargée, autre appareil).
|
||
async function attachDownload(){
|
||
const list = await jget('/api/models/download/status');
|
||
const st = (list||[]).find(d=>!d.finished);
|
||
if(st) watchDownload(st.filename);
|
||
}
|
||
|
||
// --- Recherche Hugging Face -------------------------------------------
|
||
// Deux écrans : la liste des dépôts, puis les fichiers d'un dépôt. Tout est
|
||
// construit en DOM et JAMAIS en innerHTML — noms de dépôts et de fichiers
|
||
// viennent d'un tiers, c'est la même règle que pour les titres de discussions.
|
||
let hfRepoFiles = null; // dernier dépôt déplié, pour l'installation
|
||
|
||
function hfOut(){ return document.getElementById('hf-out'); }
|
||
function hfMsg(txt, err){
|
||
const o = hfOut(); if(!o) return;
|
||
o.textContent = '';
|
||
const d = document.createElement('div');
|
||
d.className = 'pe-note';
|
||
if(err) d.style.color = 'var(--err)';
|
||
d.textContent = txt;
|
||
o.appendChild(d);
|
||
}
|
||
// Remet la zone de recherche à zéro (réouverture de la modale).
|
||
function resetHfUI(){
|
||
hfRepoFiles = null;
|
||
const o = hfOut(); if(o) o.textContent = '';
|
||
const q = document.getElementById('hf-q'); if(q) q.value = '';
|
||
}
|
||
|
||
async function hfSearch(){
|
||
const q = (document.getElementById('hf-q').value || '').trim();
|
||
if(!q){ toast('tape un nom de modèle'); return; }
|
||
hfMsg('recherche…');
|
||
let r;
|
||
try{ r = await jget('/api/hf/search?q='+encodeURIComponent(q)); }
|
||
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
|
||
if(!r.ok){ hfMsg(r.error||'recherche impossible', true); return; }
|
||
if(!r.repos || !r.repos.length){ hfMsg('aucun dépôt GGUF pour « '+q+' »'); return; }
|
||
|
||
const o = hfOut(); o.textContent = '';
|
||
const list = document.createElement('div'); list.className = 'hf-list';
|
||
for(const rep of r.repos){
|
||
const row = document.createElement('div');
|
||
row.className = 'hf-row';
|
||
row.onclick = ()=>hfPickRepo(rep.id);
|
||
const n = document.createElement('span'); n.className = 'hf-name'; n.textContent = rep.id;
|
||
const m = document.createElement('span'); m.className = 'hf-meta';
|
||
m.textContent = fmtCount(rep.downloads)+' ↓';
|
||
row.append(n, m);
|
||
list.appendChild(row);
|
||
}
|
||
o.appendChild(list);
|
||
}
|
||
|
||
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
|
||
// « celui-là est le plus utilisé », pas son compte exact.
|
||
function fmtCount(n){
|
||
n = n || 0;
|
||
if(n >= 1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+' M';
|
||
if(n >= 1e3) return Math.round(n/1e3)+' k';
|
||
return String(n);
|
||
}
|
||
|
||
async function hfPickRepo(repo){
|
||
hfMsg('lecture du dépôt…');
|
||
let r;
|
||
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
|
||
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
|
||
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
|
||
hfRepoFiles = r;
|
||
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
|
||
|
||
const o = hfOut(); o.textContent = '';
|
||
|
||
const head = document.createElement('div');
|
||
head.className = 'hf-head';
|
||
const back = document.createElement('button');
|
||
back.className = 'pe-link'; back.textContent = '← résultats';
|
||
back.onclick = hfSearch;
|
||
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
|
||
head.append(back, title);
|
||
o.appendChild(head);
|
||
|
||
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
|
||
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
|
||
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
|
||
// dit — on ne va pas en chercher un.
|
||
let mmChk = null;
|
||
const mm = (r.projectors||[])[0] && hfBestProjector(r.projectors);
|
||
const note = document.createElement('label');
|
||
note.className = 'hf-mm';
|
||
if(mm){
|
||
mmChk = document.createElement('input');
|
||
mmChk.type = 'checkbox'; mmChk.checked = true;
|
||
const t = document.createElement('span');
|
||
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
|
||
note.append(mmChk, t);
|
||
} else {
|
||
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
|
||
note.classList.add('muted');
|
||
}
|
||
o.appendChild(note);
|
||
|
||
const list = document.createElement('div'); list.className = 'hf-list';
|
||
for(const m of r.models){
|
||
const row = document.createElement('div');
|
||
row.className = 'hf-row';
|
||
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null);
|
||
const n = document.createElement('span'); n.className = 'hf-name';
|
||
n.textContent = m.quant || m.name;
|
||
const meta = document.createElement('span'); meta.className = 'hf-meta';
|
||
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
|
||
row.append(n, meta);
|
||
if(m.verdict){
|
||
const fit = document.createElement('span');
|
||
fit.className = 'hf-fit ' + m.verdict;
|
||
fit.textContent = m.verdict;
|
||
fit.title = m.why || '';
|
||
row.appendChild(fit);
|
||
}
|
||
list.appendChild(row);
|
||
}
|
||
o.appendChild(list);
|
||
|
||
if(r.hardware){
|
||
const hw = document.createElement('div');
|
||
hw.className = 'pe-note muted';
|
||
hw.textContent = r.hardware.vram_gb > 0
|
||
? 'Verdict calculé sur '+r.hardware.vram_gb.toFixed(1)+' Go de VRAM et un contexte de '+r.ctx+' jetons (estimation).'
|
||
: 'Aucun GPU détecté : verdict calculé sur '+r.hardware.ram_gb.toFixed(1)+' Go de RAM (estimation).';
|
||
o.appendChild(hw);
|
||
}
|
||
}
|
||
|
||
// Q8_0 d'abord (629 Mo contre 931 pour le BF16, sans différence perceptible sur
|
||
// un encodeur d'images), sinon le plus léger. Même règle que hfPickProjector
|
||
// côté serveur — l'UI choisit pour ne pas imposer un menu de plus.
|
||
function hfBestProjector(list){
|
||
return list.find(p => /^Q8_0$/i.test(p.quant || '')) || list.slice().sort((a,b)=>a.size-b.size)[0];
|
||
}
|
||
|
||
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
|
||
// champ Vision du preset ne se remplit correctement que dans cet ordre.
|
||
async function hfInstall(model, projector){
|
||
if(model.verdict === 'trop' && !await askConfirm(
|
||
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
|
||
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
|
||
dlQueue = projector ? [projector.url] : [];
|
||
document.getElementById('m-hf-url').value = model.url;
|
||
await startDownloadURL(model.url);
|
||
}
|
||
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
|
||
// up. Re-stick when they scroll back near bottom themselves.
|
||
let stickyBottom = true;
|
||
const chatEl = () => document.getElementById('chat');
|
||
function isNearBottom(){
|
||
const c = chatEl();
|
||
return c.scrollHeight - c.scrollTop - c.clientHeight < 60;
|
||
}
|
||
// La zone de chat réserve une gouttière de barre de défilement de chaque côté
|
||
// (scrollbar-gutter: stable both-edges) ; le composer, lui, n'est pas défilant.
|
||
// On mesure la gouttière réelle et on la reporte sur le composer, sinon les
|
||
// messages sont en retrait par rapport à la zone de saisie — décalage visible
|
||
// surtout quand la barre latérale est escamotée.
|
||
function syncGutter(){
|
||
const chat=document.getElementById('chat'); if(!chat) return;
|
||
const g=Math.max(0,(chat.offsetWidth-chat.clientWidth)/2);
|
||
document.documentElement.style.setProperty('--sbw', g+'px');
|
||
}
|
||
// La carte de saisie flotte AU-DESSUS du fil (position:absolute) : c'est au fil
|
||
// de réserver sa hauteur en rembourrage bas, sinon ses derniers messages passent
|
||
// dessous et deviennent hors d'atteinte — aucun défilement ne les ramène.
|
||
// Cette hauteur n'est pas constante : la saisie grandit avec le texte (autoGrow),
|
||
// et une pièce jointe en attente ajoute une rangée de pastilles. On la mesure
|
||
// donc au lieu de la deviner, comme on le fait déjà pour la gouttière.
|
||
// Pas de boucle d'observation : le composeur est en position:absolute, changer le
|
||
// rembourrage de #chat ne modifie pas sa hauteur.
|
||
function syncComposer(){
|
||
const c=document.getElementById('composer'); if(!c) return;
|
||
document.documentElement.style.setProperty('--composer-h', c.offsetHeight+'px');
|
||
}
|
||
addEventListener('resize', syncGutter);
|
||
addEventListener('DOMContentLoaded', syncGutter);
|
||
addEventListener('resize', syncComposer);
|
||
addEventListener('DOMContentLoaded', ()=>{
|
||
syncComposer();
|
||
const c=document.getElementById('composer');
|
||
if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c);
|
||
});
|
||
|
||
function scrollMaybe(){
|
||
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
|
||
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
|
||
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
|
||
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
|
||
if(REPLAYING) return;
|
||
if(stickyBottom){
|
||
const c = chatEl();
|
||
c.scrollTop = c.scrollHeight;
|
||
}
|
||
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
|
||
}
|
||
function jumpBottom(){ stickyBottom = true; scrollMaybe(); }
|
||
document.addEventListener('DOMContentLoaded', ()=>{
|
||
const c = chatEl();
|
||
c.addEventListener('scroll', ()=>{
|
||
stickyBottom = isNearBottom();
|
||
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
|
||
});
|
||
});
|
||
|
||
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>';
|
||
// user / assistant portent un avatar et un nom (voir 17-identity.js) ;
|
||
// reasoning et tool gardent leur libellé technique.
|
||
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
|
||
paintLabel(el.querySelector('.label'), role);
|
||
}
|
||
}
|
||
el.querySelector('.body').textContent=text;
|
||
// Bulles techniques (raisonnement, outils, mémoire) : elles se posent CÔTE À
|
||
// CÔTE dans une rangée qui déborde à la ligne. Un tour d'agent en enchaîne
|
||
// parfois dix ; empilées, elles repoussaient la réponse hors de l'écran alors
|
||
// qu'elles ne portent qu'une étiquette tant qu'elles sont repliées. Dépliée,
|
||
// une bulle reprend toute la largeur de la rangée (CSS .toolrow).
|
||
if(collapsible){
|
||
let row = chatEl().lastElementChild;
|
||
if(!row || !row.classList.contains('toolrow')){
|
||
row = document.createElement('div');
|
||
row.className = 'toolrow';
|
||
chatEl().appendChild(row);
|
||
}
|
||
row.appendChild(el);
|
||
} else {
|
||
chatEl().appendChild(el);
|
||
}
|
||
scrollMaybe();
|
||
return el;
|
||
}
|
||
// Bulle « … » animée affichée dès l'envoi, retirée au 1er token/outil/erreur.
|
||
function addTyping(){
|
||
const el=document.createElement('div');
|
||
el.className='msg assistant typing';
|
||
el.innerHTML='<span></span><span></span><span></span>';
|
||
chatEl().appendChild(el); scrollMaybe();
|
||
return el;
|
||
}
|
||
// Replie/déplie en douceur les bulles reasoning/tool. Hauteur animée en JS :
|
||
// on fige scrollHeight puis on va à 0 (fermeture) ou de 0 vers scrollHeight
|
||
// (ouverture), sans jamais dépasser. overflow:hidden clippe pendant l'animation.
|
||
function collapseBody(el){
|
||
const bw=el.querySelector('.bodywrap'); if(!bw || el.classList.contains('collapsed')) return;
|
||
bw.style.height = bw.scrollHeight+'px'; // fige les dimensions courantes
|
||
bw.style.width = bw.scrollWidth+'px';
|
||
void bw.offsetHeight; // reflow pour que la transition parte de là
|
||
el.classList.add('collapsed');
|
||
bw.style.height = '0px'; // → anime height ET width vers 0
|
||
bw.style.width = '0px';
|
||
}
|
||
function expandBody(el){
|
||
const bw=el.querySelector('.bodywrap'); if(!bw) return;
|
||
el.classList.remove('collapsed');
|
||
bw.style.height=''; bw.style.width=''; // mesure les dimensions naturelles…
|
||
const h=bw.scrollHeight, w=bw.scrollWidth;
|
||
bw.style.height='0px'; bw.style.width='0px';// …repart de 0 (pas de flash, même frame)
|
||
void bw.offsetHeight;
|
||
bw.style.height=h+'px'; bw.style.width=w+'px';
|
||
const done=e=>{ if(e.propertyName!=='height') return; bw.style.height=''; bw.style.width=''; bw.removeEventListener('transitionend',done); };
|
||
bw.addEventListener('transitionend',done);
|
||
}
|
||
function toggleCollapse(el){ el.classList.contains('collapsed') ? expandBody(el) : collapseBody(el); }
|
||
// Replie toutes les bulles d'un tour une fois la réponse finale entamée.
|
||
function collapseAll(list){ for(const el of list){ if(el) collapseBody(el); } list.length=0; }
|
||
// Replie une bulle INSTANTANÉMENT (sans animation) — utilisé pendant le replay au
|
||
// chargement pour que les vieilles bulles apparaissent déjà fermées. La classe
|
||
// 'collapsed' seule ne gère que l'opacité ; la hauteur est en style inline, donc
|
||
// on la met à 0 transition désactivée.
|
||
function collapseInstant(el){
|
||
const bw=el.querySelector('.bodywrap'); if(!bw) return;
|
||
el.classList.add('collapsed');
|
||
// Pas de `void bw.offsetHeight` ici : la bulle vient d'être créée et n'a jamais
|
||
// été peinte dépliée, donc poser height:0 n'anime pas — inutile de forcer un
|
||
// reflow par bulle (ce qui, multiplié par le replay, coûtait très cher).
|
||
bw.style.transition='none';
|
||
bw.style.height='0px'; bw.style.width='0px';
|
||
requestAnimationFrame(()=>{ bw.style.transition=''; });
|
||
}
|
||
function setLabel(el, text){ el.querySelector('.label').textContent = text; }
|
||
// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible
|
||
// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture
|
||
// survit au repli, donc on le met là plutôt que dans le corps seul.
|
||
function setLabelCounts(el, add, del){
|
||
const lab=el.querySelector('.label');
|
||
const cnt=document.createElement('span'); cnt.className='diff-count';
|
||
if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add}));
|
||
if(add && del) cnt.appendChild(document.createTextNode(' '));
|
||
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
|
||
lab.appendChild(cnt);
|
||
}
|
||
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki
|
||
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
|
||
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. 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(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); hydrateImages(b); scrollMaybe(); }
|
||
// Images servies par Loki (/api/chat/image) : une balise <img> ne peut pas
|
||
// porter d'en-tête Authorization, or /api/* exige la clé de pilotage dès qu'elle
|
||
// est définie. On récupère donc l'image par fetch authentifié et on la pose en
|
||
// blob:. Sans ça, toute instance protégée par une clé n'affichait que des images
|
||
// cassées. Les URLs externes (http…) ne sont pas touchées.
|
||
// Cache des blobs par URL source. renderBody est rappelé à CHAQUE delta du
|
||
// streaming et reconstruit le DOM : sans cache, une image déjà affichée était
|
||
// re-téléchargée à chaque token arrivé après elle. On garde l'objet URL (pas de
|
||
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
|
||
// casserait les rendus suivants qui réutilisent la même entrée.
|
||
const IMG_CACHE = new Map();
|
||
function hydrateImages(root){
|
||
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||
if(img.dataset.hydrated) return;
|
||
img.dataset.hydrated = '1';
|
||
const src = img.getAttribute('src');
|
||
img.classList.add('chatimg');
|
||
const cached = IMG_CACHE.get(src);
|
||
if(cached){ img.src = cached; return; }
|
||
try{
|
||
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
||
if(!r.ok) throw new Error(r.status);
|
||
const url = URL.createObjectURL(await r.blob());
|
||
IMG_CACHE.set(src, url);
|
||
img.src = url;
|
||
}catch(e){
|
||
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
||
// tronquée en cours de frappe (404 transitoire) et le prochain delta
|
||
// re-rend le markdown complet. On note l'échec sans le graver.
|
||
const note = document.createElement('span');
|
||
note.className = 'muted';
|
||
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
|
||
img.replaceWith(note);
|
||
}
|
||
});
|
||
}
|
||
// Render a tool call as its own conversation message: the command the model
|
||
// wrote, then the response it got back. textContent keeps it injection-safe.
|
||
function renderToolMsg(el, tu){
|
||
// Métadonnées d'affichage par outil : nom court + en-tête. Les outils web
|
||
// (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire.
|
||
const META = {
|
||
bash: {lbl:'terminal', head:'commande'},
|
||
write: {lbl:'fichier', head:'écriture'},
|
||
edit: {lbl:'édition', head:'édition'},
|
||
web_search: {lbl:'recherche', head:'recherche web'},
|
||
web_open: {lbl:'page web', head:'ouverture'},
|
||
web_read: {lbl:'page web', head:'lecture'},
|
||
web_grep: {lbl:'page web', head:'recherche'},
|
||
};
|
||
// Outils 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);
|
||
});
|
||
}
|
||
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
|
||
//
|
||
// Confirmé, car le geste emporte aussi les FICHIERS de la discussion (dépôts,
|
||
// captures, ce que l'agent y a écrit) : ils n'appartiennent qu'à elle, et plus
|
||
// un message ne les mentionnerait.
|
||
async function resetChat(){
|
||
if(!await askConfirm('Le fil de cette discussion et ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront effacés.\n\nC\'est définitif.',
|
||
{title:'Vider la discussion', okText:'Vider', danger:true})) return;
|
||
jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{});
|
||
toast('discussion vidée');
|
||
}
|
||
// Compaction : on demande à l'IA un résumé de la conversation destiné à la
|
||
// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
|
||
// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
|
||
// Compaction MANUELLE : le compactage est automatique (façon Hermes) quand le
|
||
// contexte se remplit, mais ce bouton permet de le déclencher à la demande. Le
|
||
// serveur possède la conversation : on lance la compaction côté serveur et la
|
||
// progression (bannière « compactage en cours », résultat) arrive par le flux
|
||
// d'abonnement, comme pour la génération — donc visible sur tous les appareils.
|
||
async function compactContext(){
|
||
if(!await askConfirm('Résumer les anciens tours pour libérer du contexte ? La conversation continue normalement.', {title:'Compacter le contexte', okText:'Compacter'})) return;
|
||
const btn=document.getElementById('ctx-compact'); btn.disabled=true;
|
||
try{
|
||
const r=await jfetch('/api/chat/compact',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
||
const j=await r.json().catch(()=>({}));
|
||
if(!j.ok) toast(j.error||'compaction indisponible');
|
||
}catch(e){ toast('erreur : '+(e.message||e)); }
|
||
btn.disabled=false;
|
||
}
|
||
// Persistance de la conversation : on garde user+assistant en localStorage pour
|
||
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
|
||
function saveChat(){ try{ localStorage.setItem('loki.chat', JSON.stringify(msgs)); }catch(e){} }
|
||
// Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement
|
||
// permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte,
|
||
// appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de
|
||
// localStorage : le même contexte est partagé par tous les appareils.
|
||
// Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue
|
||
// tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement)
|
||
// puis suit le direct. Partagé par tous les appareils.
|
||
// Voile de chargement du fil. setChatLoading(null) le masque, setChatLoading(txt)
|
||
// l'affiche avec ce libellé (« chargement… » au départ, « connexion au serveur… »
|
||
// si le flux tombe). Sans lui, une connexion lente affiche un chat vide qu'on ne
|
||
// distingue pas d'une conversation réellement vide.
|
||
function setChatLoading(msg){
|
||
const el=document.getElementById('chat-loading');
|
||
if(!el) return;
|
||
if(!msg){ el.classList.remove('show'); return; }
|
||
document.getElementById('chat-loading-text').textContent=msg;
|
||
el.classList.add('show');
|
||
}
|
||
// --- Accueil du fil vide ---------------------------------------------------
|
||
// Le logo n'est pas dupliqué dans le HTML : on clone celui de la barre latérale
|
||
// (#brand) en retirant ses id (un id ne peut exister qu'une fois) et le numéro
|
||
// de version. Les couleurs sont reprises par les classes .ce-*.
|
||
function fillEmptyLogo(){
|
||
const box=document.getElementById('ce-logo'), brand=document.getElementById('brand');
|
||
if(!box || !brand || box.childElementCount) return;
|
||
['brand-a','brand-word'].forEach(id=>{
|
||
const src=brand.querySelector('#'+id); if(!src) return;
|
||
const el=src.cloneNode(true); el.removeAttribute('id');
|
||
if(id==='brand-word') el.classList.add('ce-word');
|
||
box.appendChild(el);
|
||
});
|
||
}
|
||
// Affiché seulement quand le fil ne contient AUCUNE bulle et que le replay est
|
||
// terminé — sinon il apparaîtrait une fraction de seconde à chaque chargement,
|
||
// juste avant que les messages rejoués n'arrivent.
|
||
function syncChatEmpty(){
|
||
const box=document.getElementById('chat-empty'); if(!box) return;
|
||
fillEmptyLogo();
|
||
const empty = !REPLAYING && !chatEl().querySelector('.msg');
|
||
box.classList.toggle('show', empty);
|
||
}
|
||
document.addEventListener('DOMContentLoaded', ()=>{
|
||
const c=chatEl(); if(!c) return;
|
||
// Le fil est peuplé par des dizaines de chemins différents (replay, direct,
|
||
// reset, effacement). On observe donc le DOM plutôt que d'appeler la synchro
|
||
// depuis chacun d'eux — le coût est nul, le callback est groupé et sort tout
|
||
// de suite pendant le replay.
|
||
new MutationObserver(()=>syncChatEmpty()).observe(c, {childList:true});
|
||
syncChatEmpty();
|
||
});
|
||
function restoreChat(){
|
||
// On masque le chat le temps du replay pour ne pas voir défiler le haut puis
|
||
// sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au
|
||
// signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s.
|
||
const c=chatEl(); c.style.opacity='0';
|
||
setChatLoading('chargement de la conversation…');
|
||
// Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand
|
||
// même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que
|
||
// REPLAYING, donc on force ici le positionnement). Le voile, lui, RESTE : tant
|
||
// que le replay n'est pas fini, ce qui est affiché est incomplet et il faut le
|
||
// dire. Il finit de toute façon par tomber au {caught_up} ou au filet de 15s.
|
||
setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000);
|
||
setTimeout(()=>{ setChatLoading(null); }, 15000);
|
||
connectStream();
|
||
}
|
||
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
|
||
// La zone de saisie s'ajuste à son contenu : une ligne au repos, puis elle
|
||
// grandit jusqu'à sa max-height (au-delà, elle défile). Appelée à la frappe, à
|
||
// l'envoi et au chargement.
|
||
function autoGrow(ta){
|
||
ta = ta || document.getElementById('input');
|
||
if(!ta) return;
|
||
ta.style.height='auto';
|
||
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
|
||
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
|
||
ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden';
|
||
}
|
||
// ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
|
||
// L'historique et la génération vivent sur le serveur loki. Le client ouvre un
|
||
// flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis
|
||
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
|
||
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
|
||
let lastSeq=0, streamAbort=null;
|
||
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
|
||
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
|
||
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
|
||
// l'événement `user` revient par le flux — preuve que le serveur l'a bien
|
||
// enregistré. En cas d'échec d'envoi, elle est retirée et le texte est rendu.
|
||
let PENDING=null;
|
||
// Retire aussi la rangée de pièces jointes, qui vit JUSTE AVANT la bulle : sans
|
||
// ça, un envoi échoué laissait les fichiers seuls dans le fil, sans message.
|
||
function clearPending(){
|
||
if(!PENDING) return;
|
||
const f=PENDING.previousElementSibling;
|
||
if(f&&f.classList.contains('msg-files')) f.remove();
|
||
PENDING.remove(); PENDING=null;
|
||
}
|
||
function addPending(text){
|
||
clearPending();
|
||
PENDING=addMsg('user', text);
|
||
PENDING.classList.add('pending');
|
||
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();
|
||
}
|
||
// L'indicateur « … » est retiré dès que quelque chose de VISIBLE le remplace.
|
||
// Il doit donc survivre quand la bulle qui arrive ne sera pas affichée : mode
|
||
// simplifié, mais aussi raisonnement/outils masqués par les préférences — sinon
|
||
// le fil reste totalement vide pendant que le modèle travaille (rien à voir, et
|
||
// aucun signe que ça tourne).
|
||
const typingKept=(kind)=>simpleMode()
|
||
|| (kind==='reasoning' && viewOn('hide-reasoning'))
|
||
|| (kind==='tool' && viewOn('hide-tools'));
|
||
function killTyping(kind){ if(!T.typingEl) return; if(typingKept(kind)) return; removeTyping(); }
|
||
function showTyping(kind){ if(!typingKept(kind)) return; const c=document.getElementById('chat');
|
||
if(!T.typingEl){ T.typingEl=addTyping(); } else if(c.lastElementChild!==T.typingEl){ c.appendChild(T.typingEl); } }
|
||
// Label de vitesse rendu depuis les valeurs (fonctionne aussi bien en direct
|
||
// qu'au replay — pas de timer performance.now, qui n'a pas de sens hors-ligne).
|
||
function renderStats(el, s){
|
||
if(!el||!s) return;
|
||
const parts=[];
|
||
const pt=s.prompt_tokens||s.prompt_tokens_total;
|
||
if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
|
||
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
|
||
if(!parts.length) return;
|
||
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
|
||
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
|
||
// bouton de repli, on y écrit comme avant.
|
||
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(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(){
|
||
const sb=document.getElementById('send');
|
||
sb.style.display=busy?'none':'inline-block';
|
||
document.getElementById('stop').style.display=busy?'inline-block':'none';
|
||
// Tant que le moteur n'a pas fini de charger le modèle, envoyer ne mène à rien :
|
||
// on bloque le bouton et l'Entrée, et on le DIT sous le champ. `STATUS_SEEN`
|
||
// évite de verrouiller le chat quand /api/status n'a pas encore répondu (ou ne
|
||
// répond pas du tout) — dans le doute on laisse la main.
|
||
const ready = !STATUS_SEEN || MODEL_READY;
|
||
sb.disabled = !ready;
|
||
sb.title = ready ? '' : 'le modèle n\'est pas encore chargé';
|
||
const hint=document.getElementById('sendhint');
|
||
if(hint){
|
||
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
|
||
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
|
||
hint.classList.toggle('waiting', !ready);
|
||
}
|
||
}
|
||
// 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'; }
|
||
// Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
|
||
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
|
||
syncChatEmpty();
|
||
return; }
|
||
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté
|
||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
|
||
if(d.user!==undefined){
|
||
newTurn();
|
||
let el=PENDING;
|
||
if(!confirmPending(d.user)) el=addMsg('user', d.user);
|
||
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
|
||
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
|
||
// bulle neuve — sinon elles apparaîtraient en double.
|
||
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
|
||
setBusy(true); T.typingEl=addTyping(); return; }
|
||
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
||
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
||
// replay, qui rejoue tous les tours passés d'un bloc.
|
||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); 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('tool'); 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);
|
||
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
|
||
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
|
||
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
|
||
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('reasoning');
|
||
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('reasoning'); 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 Loki laissé
|
||
// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
|
||
// au-delà, toute requête (journal, installation du moteur…) reste en file
|
||
// d'attente sans jamais partir ni échouer — un blocage silencieux très
|
||
// déroutant. Au retour de l'onglet on se reconnecte, et le replay depuis
|
||
// lastSeq rattrape tout ce qui s'est passé entre-temps.
|
||
let streamPaused=false;
|
||
document.addEventListener('visibilitychange', ()=>{
|
||
if(document.hidden){
|
||
streamPaused=true;
|
||
if(streamAbort) try{ streamAbort.abort(); }catch(e){}
|
||
} else if(streamPaused){
|
||
streamPaused=false;
|
||
}
|
||
});
|
||
async function connectStream(){
|
||
while(true){
|
||
// Onglet en arrière-plan : on n'ouvre aucune connexion, on attend le retour.
|
||
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
|
||
streamAbort=new AbortController();
|
||
try{
|
||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
|
||
if(REPLAYING) setChatLoading('chargement de la conversation…');
|
||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||
while(true){
|
||
const {done,value}=await reader.read(); if(done) break;
|
||
buf+=dec.decode(value,{stream:true}); let i;
|
||
while((i=buf.indexOf('\n\n'))>=0){
|
||
const chunk=buf.slice(0,i); buf=buf.slice(i+2);
|
||
for(const line of chunk.split('\n')){
|
||
if(!line.startsWith('data:')) continue;
|
||
const data=line.slice(5).trim(); if(data===''||data==='[DONE]') continue;
|
||
try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; handleDelta(d); }catch(e){}
|
||
}
|
||
}
|
||
}
|
||
}catch(e){ /* coupure : on reconnecte silencieusement */ }
|
||
// Le flux s'est arrêté (coupure ou fin prématurée). Si le fil n'a JAMAIS fini
|
||
// de charger, le silence est trompeur — un chat vide sans explication. On le
|
||
// dit dans le voile ; il disparaîtra au {caught_up} de la reconnexion.
|
||
if(REPLAYING) setChatLoading('connexion au serveur…');
|
||
await new Promise(res=>setTimeout(res, 600));
|
||
}
|
||
}
|
||
// Interrompt la génération en cours côté serveur (la goroutine détachée est
|
||
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
|
||
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
|
||
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
|
||
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
|
||
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
|
||
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
|
||
// « network error » alarmiste alors que l'IA répond quand même.
|
||
async function send(){
|
||
if(busy) return;
|
||
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
|
||
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
|
||
const ta=document.getElementById('input'); const text=ta.value.trim();
|
||
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
|
||
if(!text && !ATTACH.length) return;
|
||
ta.value=''; autoGrow(ta);
|
||
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
|
||
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
|
||
addPending(text);
|
||
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
|
||
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
|
||
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
|
||
// on doit pouvoir en enlever une, et un échec doit rester visible.
|
||
const files=await attachPaths();
|
||
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
|
||
// Les pastilles passent dans la bulle en attente : le message porte ses
|
||
// fichiers dès l'envoi, sans attendre l'aller-retour.
|
||
if(PENDING) addMsgFiles(PENDING, attachSent());
|
||
for(let attempt=0; attempt<3; attempt++){
|
||
try{
|
||
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
|
||
if(r.status===409 || r.ok) clearAttach();
|
||
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
|
||
if(r.ok) return; // la bulle + les tokens arrivent par le flux
|
||
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
|
||
}catch(e){ /* réseau : on retente */ }
|
||
await new Promise(res=>setTimeout(res, 600));
|
||
}
|
||
// Après plusieurs échecs : le serveur a peut-être quand même reçu le message.
|
||
try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){}
|
||
fail('échec de l\'envoi — réessaie');
|
||
}
|
||
loadAll();
|
||
setInterval(loadStatus, 5000);
|
||
setInterval(loadVram, 3000);
|
||
setInterval(loadRam, 3000);
|
||
// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
|
||
// « llama.cpp précompilé » = binaires officiels, aucune compilation
|
||
// « llama.cpp compilé » = compilé ici, pour cette machine
|
||
// « llama.cpp personnalisé » = un fork (modèles à quant spéciale)
|
||
// Le CHOIX du moteur utilisé se fait par modèle (preset), pas ici — voir la
|
||
// section « Moteur » dans l'éditeur de modèle. Ça évite que la barre latérale
|
||
// et les presets se battent pour la ligne BIN.
|
||
let lcState = null, lcPoll = null, lcLogNext = 0;
|
||
|
||
async function loadLlamacpp(){
|
||
let s;
|
||
try{ s = await jget('/api/llamacpp'); }catch(_){ return; }
|
||
lcState = s;
|
||
const pb = s.prebuilt || {};
|
||
const fastInstalled = !!pb.bin;
|
||
const optInstalled = !!s.bin;
|
||
|
||
// Moteur fourni par l'image Docker : les trois modes d'installation n'ont
|
||
// aucun sens (rien à installer, aucun compilateur dans l'image). On affiche
|
||
// l'état réel à la place — les boutons du service, eux, restent utiles.
|
||
lcRenderProvided(s);
|
||
|
||
if(!s.provided){
|
||
lcRenderReco(s.reco);
|
||
lcRenderMode('fast', fastInstalled);
|
||
lcRenderMode('opt', optInstalled);
|
||
lcRenderCustomCard();
|
||
|
||
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
|
||
if(s.job && s.job.exists && s.job.running && !lcPoll){
|
||
document.getElementById('lc-details').open = true;
|
||
lcStartPolling();
|
||
}
|
||
// Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
|
||
// typiquement quand le service a redémarré) : on l'affiche sans polling.
|
||
else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
|
||
document.getElementById('lc-details').open = true;
|
||
document.getElementById('lc-job').style.display = '';
|
||
lcLogNext = 0;
|
||
document.getElementById('lc-log').textContent = '';
|
||
lcEndShown = true;
|
||
lcPollJob(true); // quiet : simple rattrapage, pas de toast ni de rechargement
|
||
}
|
||
lcChipSync(s.job);
|
||
}
|
||
// Préchauffe la liste des cartes du moteur actif : interroger le moteur prend
|
||
// 1 à 3 s (init CUDA/Vulkan), et sans ça l'encart « cartes graphiques » de
|
||
// l'éditeur apparaissait après coup, une fois le reste déjà affiché.
|
||
if(typeof prefetchGpuDevices === 'function') prefetchGpuDevices(s.config_bin);
|
||
}
|
||
|
||
// --- Pastille de rappel hors panneau ---------------------------------------
|
||
// Le détail de l'installation vit dans le panneau latéral, qui est un tiroir
|
||
// FERMÉ sur téléphone : après un rechargement, rien ne disait qu'une
|
||
// compilation tournait encore. La pastille le dit, et y ramène en un clic.
|
||
let lcSeenEnd = false, lcEndShown = false;
|
||
function lcChipLabel(action){
|
||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||
prebuilt:'Téléchargement du moteur', custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||
}
|
||
function lcChipSync(j){
|
||
const chip = document.getElementById('lc-chip');
|
||
if(!chip) return;
|
||
if(!j || !j.exists || (!j.running && (!j.error || lcSeenEnd))){ chip.hidden = true; return; }
|
||
chip.hidden = false;
|
||
chip.classList.toggle('failed', !j.running && !!j.error);
|
||
chip.textContent = j.running
|
||
? '⏳ ' + lcChipLabel(j.action) + ' — ' + (j.phase || '…')
|
||
: '✗ ' + lcChipLabel(j.action) + ' interrompue';
|
||
}
|
||
// Clic sur la pastille : ouvrir le tiroir sur la section Moteur.
|
||
function lcChipOpen(){
|
||
const side = document.getElementById('side');
|
||
if(!side.classList.contains('open')) toggleSide();
|
||
const det = document.getElementById('lc-details');
|
||
det.open = true;
|
||
det.scrollIntoView({block:'center'});
|
||
if(!document.getElementById('lc-chip').classList.contains('failed')) return;
|
||
lcSeenEnd = true;
|
||
lcChipSync(null);
|
||
}
|
||
|
||
// Place la pastille « conseillé » sur la carte que le SERVEUR recommande pour
|
||
// cette machine (voir recommendedMode) : le précompilé partout, sauf sur Linux
|
||
// avec une carte NVIDIA où il ne donnerait que du Vulkan. La raison est ajoutée
|
||
// à la description de la carte, pour expliquer plutôt que d'imposer.
|
||
function lcRenderReco(reco){
|
||
const mode = (reco && reco.mode) || 'fast';
|
||
for(const m of ['fast','opt']){
|
||
const badge = document.getElementById('lc-reco-'+m);
|
||
if(badge) badge.hidden = (m !== mode);
|
||
}
|
||
// La raison REMPLACE la description générique de la carte conseillée : elle
|
||
// dit déjà ce que fait l'option et pourquoi c'est le bon choix ici. (loadAll
|
||
// repasse par là, donc pas d'accumulation possible.)
|
||
const desc = document.getElementById('lc-desc-'+mode);
|
||
if(desc && reco && reco.why) desc.textContent = reco.why;
|
||
}
|
||
|
||
// Bascule entre « moteur de l'image » (une ligne d'état) et les trois modes
|
||
// d'installation gérés par Loki.
|
||
function lcRenderProvided(s){
|
||
const box = document.getElementById('lc-provided');
|
||
const modes = document.querySelector('.lc-modes');
|
||
if(!box || !modes) return;
|
||
box.style.display = s.provided ? '' : 'none';
|
||
modes.style.display = s.provided ? 'none' : '';
|
||
if(!s.provided) return;
|
||
const bin = document.getElementById('lc-provided-bin');
|
||
if(bin) bin.textContent = s.config_bin || '';
|
||
const job = document.getElementById('lc-job');
|
||
if(job) job.style.display = 'none';
|
||
}
|
||
|
||
function lcRenderMode(mode, installed){
|
||
const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt');
|
||
const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state');
|
||
card.classList.toggle('installed', installed);
|
||
// Lien de vérification : interroge la dernière version SANS rien installer.
|
||
// event.stopPropagation empêche le clic de la carte (qui lance l'install).
|
||
const check = '<span class="lc-update-link" onclick="event.stopPropagation();lcCheck(\''+mode+'\')">vérifier la version</span>';
|
||
if(installed){
|
||
state.innerHTML = '<span class="lc-mode-active-tag">✓ installée</span>'
|
||
+ '<span class="lc-update-link" onclick="event.stopPropagation();lcUpdate(\''+mode+'\')">↻ mettre à jour</span>'
|
||
+ check;
|
||
} else {
|
||
state.innerHTML = '<span class="lc-mode-go">→ cliquer pour installer</span>' + check;
|
||
}
|
||
}
|
||
|
||
// lcCheck vérifie s'il existe une version plus récente AVANT toute installation
|
||
// (endpoints de check dédiés, sans effet de bord). Résultat affiché en toast.
|
||
async function lcCheck(mode){
|
||
toast('vérification…');
|
||
try{
|
||
if(mode === 'fast'){
|
||
const r = await jpost('/api/llamacpp/prebuilt/check', {});
|
||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||
if(r.update) toast('nouvelle version disponible : '+r.latest+(r.current ? ' (installée : '+r.current+')' : ''));
|
||
else toast('llama.cpp précompilé à jour ✓'+(r.latest ? ' ('+r.latest+')' : ''));
|
||
} else {
|
||
const r = await jpost('/api/llamacpp/check', {});
|
||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||
if(r.behind > 0) toast(r.behind+' nouveau(x) commit(s) disponible(s) — utilisez « mettre à jour »');
|
||
else toast('llama.cpp compilé à jour ✓');
|
||
}
|
||
}catch(_){ toast('erreur réseau'); }
|
||
}
|
||
|
||
// Clic sur une carte : installer le moteur (s'il ne l'est pas déjà).
|
||
async function lcPick(mode){
|
||
const s = lcState || {}, pb = s.prebuilt || {};
|
||
const installed = mode==='fast' ? !!pb.bin : !!s.bin;
|
||
if(installed){
|
||
toast('déjà installée — choisissez-la dans l\'édition d\'un modèle (⚙)');
|
||
return;
|
||
}
|
||
if(mode === 'fast'){
|
||
if(!await askConfirm('Télécharger le binaire officiel de llama.cpp, prêt à l\'emploi (~2 min, aucune compilation).', {title:'llama.cpp précompilé', okText:'Installer'})) return;
|
||
const r = await jpost('/api/llamacpp/prebuilt', {});
|
||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||
} else {
|
||
if(!await askConfirm('Compiler llama.cpp pour votre machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA).', {title:'llama.cpp compilé', okText:'Compiler'})) return;
|
||
const r = await jpost('/api/llamacpp/install', {});
|
||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||
}
|
||
lcStartPolling();
|
||
}
|
||
|
||
// « Mettre à jour » sur une carte installée.
|
||
async function lcUpdate(mode){
|
||
if(mode === 'fast'){
|
||
if(!await askConfirm('Vérifier et installer le dernier binaire précompilé.', {title:'Mettre à jour', okText:'Mettre à jour'})) return;
|
||
const r = await jpost('/api/llamacpp/prebuilt', {});
|
||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||
} else {
|
||
if(!await askConfirm('Vérifier et installer la dernière version compilée (recompilation si besoin).', {title:'Mettre à jour', okText:'Mettre à jour'})) return;
|
||
const r = await jpost('/api/llamacpp/update', {clean:false});
|
||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||
}
|
||
lcStartPolling();
|
||
}
|
||
|
||
// --- Backends personnalisés (3e carte + modal de gestion) ------------------
|
||
let lcCustomBackends = [];
|
||
|
||
// État affiché sur la carte « Backend personnalisé » : nombre d'installés.
|
||
async function lcRenderCustomCard(){
|
||
const state = document.getElementById('lc-custom-state');
|
||
if(!state) return;
|
||
try{ lcCustomBackends = await jget('/api/backends/custom') || []; }catch(_){ lcCustomBackends = []; }
|
||
const n = lcCustomBackends.length;
|
||
document.getElementById('lc-mode-custom').classList.toggle('installed', n>0);
|
||
state.innerHTML = n>0
|
||
? '<span class="lc-mode-active-tag">✓ '+n+' installé'+(n>1?'s':'')+'</span><span class="lc-mode-go">→ gérer</span>'
|
||
: '<span class="lc-mode-go">→ voir / installer</span>';
|
||
}
|
||
|
||
function openCustomBackends(){
|
||
showModal('lc-custom-modal');
|
||
document.getElementById('lc-custom-url').value = '';
|
||
document.getElementById('lc-custom-name').value = '';
|
||
loadCustomBackends();
|
||
}
|
||
function closeCustomBackends(){ hideModal('lc-custom-modal'); }
|
||
|
||
// Liste les backends custom dans le modal, avec un bouton supprimer par ligne.
|
||
async function loadCustomBackends(){
|
||
const box = document.getElementById('lc-custom-list');
|
||
box.innerHTML = '<span class="muted" style="font-size:12px">chargement…</span>';
|
||
let list = [];
|
||
try{ list = await jget('/api/backends/custom') || []; }catch(_){}
|
||
lcCustomBackends = list;
|
||
if(!list.length){ box.innerHTML = '<span class="muted" style="font-size:12px">aucun backend personnalisé pour l\'instant.</span>'; return; }
|
||
box.innerHTML = list.map(b=>{
|
||
const nm = String(b.name).replace(/[<>&]/g,'');
|
||
const used = b.in_use ? '<span class="mcp-tag" style="border-color:var(--accent);color:var(--accent)">utilisé</span>' : '';
|
||
return '<div class="mcp-row" style="cursor:default">'
|
||
+ '<span class="mcp-dot '+(b.in_use?'mcp-dot-ok':'mcp-dot-off')+'"></span>'
|
||
+ '<div class="mcp-info"><div class="mcp-name">'+nm+'</div>'
|
||
+ '<div class="mcp-meta">'+used+'<span class="mcp-tag" title="'+String(b.path).replace(/"/g,'"')+'">'+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;
|
||
lcSeenEnd = false; lcEndShown = false;
|
||
lcBusy(true);
|
||
if(lcPoll) clearInterval(lcPoll);
|
||
lcPoll = setInterval(lcPollJob, 1000);
|
||
lcPollJob();
|
||
}
|
||
|
||
async function lcPollJob(quiet){
|
||
let j;
|
||
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
|
||
if(!j.exists) return;
|
||
const phaseEl = document.getElementById('lc-job-phase');
|
||
if(j.lines && j.lines.length){
|
||
const pre = document.getElementById('lc-log');
|
||
const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20;
|
||
pre.textContent += j.lines.join('\n') + '\n';
|
||
if(stick) pre.scrollTop = pre.scrollHeight;
|
||
}
|
||
if(typeof j.next === 'number') lcLogNext = j.next;
|
||
lcChipSync(j);
|
||
if(j.running){
|
||
phaseEl.innerHTML = '<span class="lc-spin">⏳</span> <span>'+String(j.phase||'…').replace(/[<>&]/g,'')+'</span>';
|
||
return;
|
||
}
|
||
if(lcPoll){ clearInterval(lcPoll); lcPoll = null; }
|
||
lcBusy(false);
|
||
if(j.error){
|
||
phaseEl.innerHTML = '<span style="color:var(--err)">✗ '+String(j.error).replace(/[<>&]/g,'')+'</span>';
|
||
const pre = document.getElementById('lc-log');
|
||
if(pre.hasAttribute('hidden')) lcToggleLog();
|
||
pre.scrollTop = pre.scrollHeight;
|
||
toast('échec — voir les détails');
|
||
} else {
|
||
phaseEl.innerHTML = '<span style="color:var(--ok)">✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+'</span>';
|
||
toast('c\'est prêt ✓');
|
||
}
|
||
loadAll();
|
||
}
|
||
|
||
function lcToggleLog(){
|
||
const pre = document.getElementById('lc-log');
|
||
const bar = document.querySelector('.lc-logbar');
|
||
if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; }
|
||
else { pre.setAttribute('hidden',''); bar.classList.remove('open'); }
|
||
}
|
||
// ─── Serveurs MCP ────────────────────────────────────────────────────────────
|
||
// Gestion des serveurs MCP (Model Context Protocol) : liste, ajout/édition via
|
||
// une modale, activation/désactivation, test de connexion. Sous-réglage du mode
|
||
// agent (grisé quand l'agent est off, comme mémoire/internet).
|
||
let mcpServers = [];
|
||
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
|
||
|
||
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
|
||
|
||
function renderMCP(r){
|
||
mcpServers = (r && r.servers) || [];
|
||
// Pas de pastille de comptage : chaque serveur affiche déjà son propre état
|
||
// dans la liste juste en dessous.
|
||
setBadge('mcp-badge', null);
|
||
const list = document.getElementById('mcp-list');
|
||
list.textContent = '';
|
||
if(!mcpServers.length){
|
||
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div>';
|
||
return;
|
||
}
|
||
mcpServers.forEach(s=>{
|
||
const row = document.createElement('div');
|
||
row.className = 'mcp-row'+(s.enabled?'':' off');
|
||
|
||
// Pastille d'état : bleu accent = connecté, rouge = erreur, gris = éteint.
|
||
const dot = document.createElement('span');
|
||
dot.className = 'mcp-dot';
|
||
if(!s.enabled) dot.classList.add('mcp-dot-off');
|
||
else if(s.connected) dot.classList.add('mcp-dot-ok');
|
||
else dot.classList.add('mcp-dot-err');
|
||
dot.title = !s.enabled ? 'désactivé' : (s.connected ? 'connecté' : 'erreur de connexion');
|
||
|
||
const info = document.createElement('div');
|
||
info.className = 'mcp-info';
|
||
const nm = document.createElement('div'); nm.className = 'mcp-name';
|
||
nm.textContent = s.name; nm.title = s.name;
|
||
const meta = document.createElement('div'); meta.className = 'mcp-meta';
|
||
const tr = document.createElement('span'); tr.className = 'mcp-tag';
|
||
tr.textContent = s.transport || '?'; meta.appendChild(tr);
|
||
if(s.enabled && s.connected){
|
||
const total = s.tools ? s.tools.length : 0;
|
||
const off = s.disabled ? s.disabled.length : 0;
|
||
const active = total - off;
|
||
const tc = document.createElement('span'); tc.className = 'mcp-tools';
|
||
tc.textContent = off ? (active+'/'+total+' outils') : (total+' outil'+(total>1?'s':''));
|
||
if(s.tools && s.tools.length) tc.title = s.tools.join(', ');
|
||
meta.appendChild(tc);
|
||
} else if(s.enabled && s.error){
|
||
const er = document.createElement('span'); er.className = 'mcp-err';
|
||
er.textContent = 'erreur'; er.title = s.error;
|
||
meta.appendChild(er);
|
||
}
|
||
info.appendChild(nm); info.appendChild(meta);
|
||
|
||
// Interrupteur activer/désactiver. stopPropagation sur le label ENTIER (pas
|
||
// seulement la checkbox) : sinon un clic sur le slider remonte à la ligne et
|
||
// ouvre la modale en même temps que le toggle.
|
||
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
|
||
sw.onclick = (e)=>e.stopPropagation();
|
||
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!s.enabled;
|
||
cb.onchange = ()=>toggleMcp(s.name, cb.checked);
|
||
const sl = document.createElement('span'); sl.className = 'slider';
|
||
sw.appendChild(cb); sw.appendChild(sl);
|
||
|
||
const edit = document.createElement('button');
|
||
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.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);
|
||
showModal('mcp-modal');
|
||
}
|
||
|
||
// Liste des outils découverts du serveur, avec une case par outil pour le
|
||
// masquer/exposer à l'IA. Visible seulement pour un serveur connecté.
|
||
function renderMcpTools(s){
|
||
const sec = document.getElementById('mcp-tools-section');
|
||
const list = document.getElementById('mcp-tools-list');
|
||
list.textContent = '';
|
||
if(!s || !s.connected || !s.tools || !s.tools.length){ sec.style.display = 'none'; return; }
|
||
sec.style.display = '';
|
||
const off = new Set(s.disabled || []);
|
||
document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)';
|
||
s.tools.slice().sort().forEach(tool=>{
|
||
const row = document.createElement('label'); row.className = 'mcp-tool-row';
|
||
const sw = document.createElement('span'); sw.className = 'switch mcp-tool-switch';
|
||
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !off.has(tool);
|
||
cb.onchange = ()=>toggleMcpTool(s.name, tool, cb.checked);
|
||
const sl = document.createElement('span'); sl.className = 'slider';
|
||
sw.appendChild(cb); sw.appendChild(sl);
|
||
const nm = document.createElement('code'); nm.className = 'mcp-tool-name'; nm.textContent = tool;
|
||
row.appendChild(sw); row.appendChild(nm);
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function toggleMcpTool(server, tool, on){
|
||
const r = await jpost('/api/mcp/tool', {name: server, tool, on});
|
||
renderMCP(r);
|
||
// Rafraîchit le compteur de la modale à partir de l'état à jour.
|
||
const s = (r.servers||[]).find(x=>x.name===server);
|
||
const off = s && s.disabled ? new Set(s.disabled) : new Set();
|
||
if(s && s.tools) document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)';
|
||
}
|
||
|
||
function closeMcp(){ hideModal('mcp-modal'); mcpEditing = null; }
|
||
|
||
function mcpTransportUI(t){
|
||
document.getElementById('mcp-stdio-fields').style.display = t==='stdio' ? '' : 'none';
|
||
document.getElementById('mcp-http-fields').style.display = t==='http' ? '' : 'none';
|
||
}
|
||
|
||
// Parse un textarea "KEY<sep>valeur" par ligne → objet. sep = '=' (env) ou ':' (headers).
|
||
function mcpParseKV(text, sep){
|
||
const out = {};
|
||
(text||'').split('\n').forEach(line=>{
|
||
const s = line.trim();
|
||
if(!s) return;
|
||
const i = s.indexOf(sep);
|
||
if(i <= 0) return;
|
||
out[s.slice(0,i).trim()] = s.slice(i+1).trim();
|
||
});
|
||
return Object.keys(out).length ? out : undefined;
|
||
}
|
||
|
||
async function saveMcp(){
|
||
const name = document.getElementById('mcp-name').value.trim();
|
||
if(!name){ toast('donne un nom au serveur'); return; }
|
||
const transport = document.querySelector('input[name="mcp-transport"]:checked').value;
|
||
const body = {name};
|
||
if(transport==='stdio'){
|
||
body.command = document.getElementById('mcp-command').value.trim();
|
||
if(!body.command){ toast('renseigne la commande'); return; }
|
||
body.args = document.getElementById('mcp-args').value.split('\n').map(x=>x.trim()).filter(Boolean);
|
||
body.env = mcpParseKV(document.getElementById('mcp-env').value, '=');
|
||
body.url = ''; body.headers = undefined;
|
||
} else {
|
||
body.url = document.getElementById('mcp-url').value.trim();
|
||
if(!body.url){ toast("renseigne l'URL"); return; }
|
||
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
|
||
body.command = ''; body.args = undefined; body.env = undefined;
|
||
}
|
||
const st = document.getElementById('mcp-modal-status');
|
||
st.textContent = 'connexion…'; st.style.color = '';
|
||
const r = await jpost('/api/mcp/save', body);
|
||
if(!r.ok){ st.textContent = '⚠ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; }
|
||
renderMCP(r);
|
||
const saved = (r.servers||[]).find(x=>x.name===name);
|
||
if(saved && saved.connected){ toast('serveur connecté — '+(saved.tools?saved.tools.length:0)+' outil(s)'); closeMcp(); }
|
||
else if(saved && saved.error){ st.textContent = '⚠ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; }
|
||
else { toast('enregistré'); closeMcp(); }
|
||
}
|
||
|
||
async function deleteMcp(){
|
||
if(!mcpEditing) return;
|
||
if(!await askConfirm('Retirer le serveur « '+mcpEditing+' » ? Ses outils ne seront plus proposés à l\'IA.', {title:'Supprimer le serveur MCP ?', okText:'Supprimer', danger:true})) return;
|
||
await jpost('/api/mcp/delete', {name: mcpEditing});
|
||
closeMcp();
|
||
loadMCP();
|
||
toast('serveur supprimé');
|
||
}
|
||
// ===== Pièces jointes du chat ==============================================
|
||
// Un fichier joint n'est PAS lu par le navigateur ni injecté dans le contexte :
|
||
// il est déposé tel quel dans le dossier de travail de l'IA (uploads/, côté
|
||
// serveur), et seul son chemin est annoncé dans le message. Le modèle l'ouvre
|
||
// s'il en a besoin, avec ses outils — ce qui marche pour n'importe quel type de
|
||
// fichier sans que l'UI ait à en connaître le format.
|
||
//
|
||
// Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la
|
||
// seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant
|
||
// fonctionne sans code particulier.
|
||
// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go).
|
||
// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent
|
||
// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer
|
||
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
|
||
const ATTACH_MAX = 1024*1024*1024;
|
||
const ATTACH_CHUNK = 8*1024*1024;
|
||
// Délai par MORCEAU (et pas les 30 s d'un appel /api/* ordinaire) : une tranche de
|
||
// 8 Mo, base64 + tunnel E2E, peut dépasser 30 s sur une liaison lente.
|
||
const UPLOAD_CHUNK_TIMEOUT_MS = 120000;
|
||
let ATTACH = [];
|
||
let ATTACH_SEQ = 0;
|
||
|
||
function fmtSize(n){
|
||
if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go';
|
||
if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo';
|
||
if(n >= 1024) return Math.round(n/1024)+' Ko';
|
||
return n+' o';
|
||
}
|
||
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
|
||
// objet visuel des deux côtés, seul le contexte (CSS) change.
|
||
function fileChip(name, size, opts){
|
||
opts=opts||{};
|
||
const chip=document.createElement('div');
|
||
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
|
||
const n=document.createElement('span');
|
||
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
|
||
const s=document.createElement('span');
|
||
s.className='cf-size'; s.textContent=opts.sizeText||fmtSize(size);
|
||
chip.appendChild(n); chip.appendChild(s);
|
||
if(opts.onRemove){
|
||
const x=document.createElement('button');
|
||
x.type='button'; x.textContent='×'; x.title='retirer';
|
||
x.setAttribute('aria-label','retirer '+name);
|
||
x.onclick=opts.onRemove;
|
||
chip.appendChild(x);
|
||
}
|
||
return chip;
|
||
}
|
||
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
|
||
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
|
||
// un bloc bâtard, moitié texte moitié pastilles.
|
||
function addMsgFiles(el, files){
|
||
if(!el || !files || !files.length) return;
|
||
const box=document.createElement('div');
|
||
box.className='msg-files';
|
||
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||
el.parentNode.insertBefore(box, el);
|
||
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
|
||
markEmptyMsg(el);
|
||
return box;
|
||
}
|
||
// Une bulle sans texte n'a rien à montrer — seules ses pièces jointes parlent.
|
||
function markEmptyMsg(el){
|
||
const b=el&&el.querySelector('.body');
|
||
if(b) el.classList.toggle('empty', b.textContent==='');
|
||
}
|
||
// La rangée de fichiers précède la bulle : c'est là qu'on vérifie sa présence
|
||
// avant d'en ajouter une seconde (replay + bulle en attente).
|
||
function hasMsgFiles(el){
|
||
const p=el&&el.previousElementSibling;
|
||
return !!(p&&p.classList.contains('msg-files'));
|
||
}
|
||
// ===== Fichiers remis par l'IA ============================================
|
||
// Aucun outil, aucune syntaxe maison : l'IA écrit un lien Markdown ordinaire
|
||
// vers un fichier de son dossier de travail — [Télécharger](rapport.pdf) — et
|
||
// c'est l'UI qui le transforme en téléchargement. Le modèle n'a rien à savoir
|
||
// de plus que ce qu'il fait déjà naturellement.
|
||
//
|
||
// Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête
|
||
// Authorization que le navigateur ne mettrait pas sur une navigation, et le
|
||
// chemin de base change derrière le tunnel (/u/<id>). D'où le fetch + blob.
|
||
const DL_CHUNK = 8*1024*1024; // aligné sur downloadChunkMax (web_upload.go)
|
||
// Décode une tranche base64 en octets.
|
||
function b64ToBytes(s){
|
||
const bin=atob(s), out=new Uint8Array(bin.length);
|
||
for(let i=0;i<bin.length;i++) out[i]=bin.charCodeAt(i);
|
||
return out;
|
||
}
|
||
// Récupère le fichier en tranches base64. C'est la voie OBLIGATOIRE derrière
|
||
// app.ajean.link : le proxy chiffré réemballe toute réponse en JSON, et du
|
||
// binaire n'y survit pas — on téléchargeait l'enveloppe JSON à la place du
|
||
// fichier. Le base64, lui, traverse intact.
|
||
async function fetchFileB64(path, size, chip){
|
||
const parts=[]; let off=0;
|
||
do{
|
||
const r=await jfetch('/api/chat/file?b64=1&len='+DL_CHUNK+'&offset='+off+'&path='+encodeURIComponent(path));
|
||
const j=await r.json();
|
||
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
|
||
const bytes=b64ToBytes(j.data||'');
|
||
parts.push(bytes);
|
||
// Une tranche vide sans marque de fin ne ferait pas avancer la boucle : on
|
||
// s'arrête plutôt que de tourner indéfiniment sur le même octet.
|
||
if(!bytes.length && !j.eof) throw new Error('transfert interrompu');
|
||
off=(j.offset||0)+bytes.length;
|
||
if(chip && size) chip.title='téléchargement '+Math.round(off*100/size)+' %';
|
||
if(j.eof) break;
|
||
} while(off<size);
|
||
return new Blob(parts);
|
||
}
|
||
async function downloadWorkspaceFile(path, name, a){
|
||
if(a) a.classList.add('busy');
|
||
const wasTitle=a?a.title:'';
|
||
try{
|
||
// La fiche d'abord : elle est minuscule, elle passe partout, et elle dit si
|
||
// on est derrière le tunnel — ce que le client ne peut pas deviner seul, le
|
||
// proxy lui rendant des réponses JSON parfaitement ordinaires.
|
||
const m=await jfetch('/api/chat/file?meta=1&path='+encodeURIComponent(path));
|
||
const meta=await m.json().catch(()=>({}));
|
||
if(!m.ok || !meta.ok){
|
||
toast('téléchargement impossible : '+(meta.error||('HTTP '+m.status))); return;
|
||
}
|
||
let blob;
|
||
if(meta.e2e){
|
||
blob=await fetchFileB64(path, meta.size||0, a);
|
||
} else {
|
||
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
|
||
if(!r.ok){
|
||
let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){}
|
||
toast('téléchargement impossible : '+msg); return;
|
||
}
|
||
blob=await r.blob();
|
||
}
|
||
const blobURL=URL.createObjectURL(blob);
|
||
const link=document.createElement('a');
|
||
link.href=blobURL; link.download=name||meta.name||'fichier';
|
||
document.body.appendChild(link); link.click(); link.remove();
|
||
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
|
||
// dans la foulée du clic.
|
||
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
|
||
}catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); }
|
||
finally{ if(a){ a.classList.remove('busy'); a.title=wasTitle; } }
|
||
}
|
||
// Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle
|
||
// écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un
|
||
// lien mort. Or les noms de fichiers à espaces sont la règle, pas l'exception. On
|
||
// les encode donc avant le rendu (fileLinkPath les redécode).
|
||
//
|
||
// Les cibles avec un titre — [x](chemin "Titre") — sont laissées tranquilles :
|
||
// leurs espaces sont syntaxiques.
|
||
function encodeMdLinkSpaces(text){
|
||
return String(text).replace(/\]\(([^)\n]*)\)/g, (whole, target)=>{
|
||
if(target.indexOf(' ')<0 || /["']/.test(target)) return whole;
|
||
if(/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return whole; // URL : pas notre affaire
|
||
return '](' + target.replace(/ /g, '%20') + ')';
|
||
});
|
||
}
|
||
// Chemin visé par un lien de la réponse, ramené au dossier de travail — ou ''
|
||
// si le lien pointe ailleurs (page web, ancre, mailto…).
|
||
//
|
||
// Le modèle écrit indifféremment un chemin relatif, un chemin absolu du serveur,
|
||
// ou le préfixe `sandbox:` qu'il a vu ailleurs : les trois doivent marcher, on
|
||
// ne va pas lui demander d'être rigoureux sur un détail pareil.
|
||
function fileLinkPath(href){
|
||
if(!href) return '';
|
||
let h=href.trim();
|
||
if(/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(h)) return '';
|
||
h=h.replace(/^sandbox:/i,'').replace(/^file:\/\//i,'');
|
||
try{ h=decodeURI(h); }catch(_){}
|
||
h=h.replace(/\\/g,'/');
|
||
// Chemin absolu : on ne garde que ce qui suit le dossier de travail. Le serveur
|
||
// revérifie de toute façon — c'est lui qui fait autorité sur le périmètre.
|
||
const m=h.match(/(?:^|\/)workspace\/(.+)$/);
|
||
if(m) h=m[1];
|
||
h=h.replace(/^\/+/,'').replace(/^\.\//,'');
|
||
if(!h || h.indexOf('..')>=0) return '';
|
||
return h;
|
||
}
|
||
// Transforme en téléchargements les liens d'une réponse qui visent un fichier.
|
||
function markFileLinks(root){
|
||
for(const a of root.querySelectorAll('a[href]')){
|
||
const p=fileLinkPath(a.getAttribute('href'));
|
||
if(!p) continue;
|
||
a.classList.add('filelink');
|
||
a.removeAttribute('target');
|
||
a.setAttribute('href','#');
|
||
const name=p.split('/').pop();
|
||
a.title='télécharger '+name;
|
||
a.onclick=(e)=>{ e.preventDefault(); downloadWorkspaceFile(p, name, a); };
|
||
}
|
||
}
|
||
function attachListEl(){ return document.getElementById('attach-list'); }
|
||
function renderAttach(){
|
||
const el = attachListEl(); if(!el) return;
|
||
el.innerHTML='';
|
||
el.classList.toggle('show', ATTACH.length>0);
|
||
for(const a of ATTACH){
|
||
el.appendChild(fileChip(a.name, a.size, {
|
||
cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''),
|
||
title: a.error || a.name,
|
||
// Un gros fichier prend du temps : on montre l'avancement plutôt qu'un
|
||
// anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre.
|
||
sizeText: a.state==='err' ? 'échec'
|
||
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
|
||
: fmtSize(a.size),
|
||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
|
||
}));
|
||
}
|
||
}
|
||
function clearAttach(){ ATTACH=[]; renderAttach(); }
|
||
// Ce qui part avec le message, pour l'afficher dans sa bulle.
|
||
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
|
||
|
||
// Lecture en base64. readAsDataURL préfixe par "data:<mime>;base64," — le
|
||
// serveur sait retirer cet en-tête, on n'a donc rien à découper ici.
|
||
function readAsBase64(file){
|
||
return new Promise((res,rej)=>{
|
||
const fr=new FileReader();
|
||
fr.onload=()=>res(String(fr.result||''));
|
||
fr.onerror=()=>rej(new Error('lecture impossible'));
|
||
fr.readAsDataURL(file);
|
||
});
|
||
}
|
||
// Envoie UN morceau. VIA jfetch, pas un fetch brut : derrière app.ajean.link,
|
||
// jfetch est réécrit pour traverser la boîte noire E2E — un POST direct vers
|
||
// /api/chat/upload n'atteint PAS ton serveur (le relais est aveugle) et l'envoi
|
||
// échouait « impossible d'envoyer ». On fournit notre PROPRE signal pour ne pas
|
||
// hériter du plafond de 30 s de jfetch (calibré pour de petits appels), un morceau
|
||
// pouvant être plus long ; le download passe déjà par jfetch, on symétrise.
|
||
async function sendChunk(payload){
|
||
const ac=new AbortController();
|
||
const timer=setTimeout(()=>ac.abort(), UPLOAD_CHUNK_TIMEOUT_MS);
|
||
try{
|
||
const r=await jfetch('/api/chat/upload',{
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify(payload), signal:ac.signal
|
||
});
|
||
const j=await r.json().catch(()=>({}));
|
||
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
|
||
return j;
|
||
} finally { clearTimeout(timer); }
|
||
}
|
||
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
|
||
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
|
||
// un fichier de la liste ne laisse donc aucune trace sur le disque.
|
||
//
|
||
// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois
|
||
// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe
|
||
// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on
|
||
// en est, ce qu'un envoi monolithique ne permettait pas.
|
||
async function uploadAttach(a){
|
||
a.state='up'; a.sent=0; renderAttach();
|
||
try{
|
||
let id='';
|
||
// `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand
|
||
// même faire un tour de boucle, sinon rien ne part.
|
||
let off=0;
|
||
do{
|
||
const end=Math.min(off+ATTACH_CHUNK, a.size);
|
||
const data=await readAsBase64(a.file.slice(off, end));
|
||
const last=end>=a.size;
|
||
// `size` au premier morceau : le serveur vérifie la place disque AVANT
|
||
// d'entamer un envoi d'un gigaoctet, plutôt que de le découvrir à la fin.
|
||
const j=await sendChunk({name:a.name, data:data, id:id, more:!last, size:(id?0:a.size)});
|
||
if(j.id) id=j.id;
|
||
off=end;
|
||
a.sent=off; renderAttach();
|
||
if(last){ a.path=j.path; }
|
||
} while(off<a.size);
|
||
a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
|
||
}catch(e){
|
||
a.state='err'; a.error=(e&&e.message)||'échec';
|
||
toast('« '+a.name+' » : '+a.error);
|
||
}
|
||
renderAttach();
|
||
}
|
||
function addFiles(files){
|
||
for(const f of files||[]){
|
||
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
|
||
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
|
||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
|
||
}
|
||
renderAttach();
|
||
}
|
||
function onAttachPick(e){
|
||
addFiles(e.target.files);
|
||
e.target.value=''; // sinon re-choisir le même fichier ne déclenche pas change
|
||
}
|
||
// Coller un fichier (capture d'écran, fichier copié depuis l'explorateur). On ne
|
||
// touche à rien quand le presse-papier ne contient que du texte : le collage
|
||
// normal dans la zone de saisie doit rester intact.
|
||
function onPaste(e){
|
||
const items=(e.clipboardData&&e.clipboardData.files)||[];
|
||
if(!items.length) return;
|
||
e.preventDefault();
|
||
addFiles(items);
|
||
}
|
||
// Glisser-déposer sur toute la fenêtre. Le compteur de profondeur est nécessaire :
|
||
// dragenter/dragleave se déclenchent aussi au passage d'un enfant à l'autre, et
|
||
// sans lui le voile clignote dès que le curseur traverse une bulle du fil.
|
||
let DRAG_DEPTH=0;
|
||
function dropVeil(on){
|
||
const v=document.getElementById('dropveil'); if(v) v.classList.toggle('show', on);
|
||
}
|
||
function hasFiles(e){
|
||
const dt=e.dataTransfer; if(!dt) return false;
|
||
if(dt.types) for(const t of dt.types){ if(t==='Files') return true; }
|
||
return false;
|
||
}
|
||
window.addEventListener('dragenter', e=>{ if(!hasFiles(e)) return; e.preventDefault(); DRAG_DEPTH++; dropVeil(true); });
|
||
window.addEventListener('dragover', e=>{ if(hasFiles(e)) e.preventDefault(); });
|
||
window.addEventListener('dragleave', e=>{ if(!hasFiles(e)) return; DRAG_DEPTH=Math.max(0,DRAG_DEPTH-1); if(!DRAG_DEPTH) dropVeil(false); });
|
||
window.addEventListener('drop', e=>{
|
||
if(!hasFiles(e)) return;
|
||
e.preventDefault(); DRAG_DEPTH=0; dropVeil(false);
|
||
addFiles(e.dataTransfer.files);
|
||
});
|
||
// Appelé par send() : c'est ICI que les fichiers partent réellement vers le
|
||
// serveur. Ceux qui échouent sont ignorés — leur pastille reste à l'écran en
|
||
// rouge, elle dit déjà ce qui s'est passé.
|
||
async function attachPaths(){
|
||
await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach));
|
||
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
|
||
}
|
||
renderAttach();
|
||
// ─── Fichiers de la discussion ───────────────────────────────────────────────
|
||
// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici
|
||
// on ne pouvait récupérer un fichier que si le modèle avait pensé à en mettre le
|
||
// lien dans sa réponse — tout le reste restait invisible, et faire le ménage
|
||
// demandait un shell.
|
||
//
|
||
// Le panneau montre les fichiers de la discussion OUVERTE, et rien d'autre :
|
||
// chaque discussion a son dossier côté serveur (chat_convfiles.go), changer de
|
||
// discussion change donc la liste, et supprimer une discussion emporte ses
|
||
// fichiers. `FILES_SCOPE = 'legacy'` ouvre le détour « hors discussion » : les
|
||
// fichiers d'avant ce rangement, que la migration n'a pas su rattacher.
|
||
//
|
||
// Le panneau est construit EN DOM, jamais en innerHTML : les noms de fichiers
|
||
// viennent du modèle et du système de fichiers, donc d'ailleurs. Même règle que
|
||
// pour les titres de discussions (16-conversations.js).
|
||
//
|
||
// Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
|
||
// déjà gérer le blob et les cas particuliers du navigateur.
|
||
|
||
let FILES_DIR = ''; // dossier affiché, relatif à la racine du panneau
|
||
let FILES_SCOPE = ''; // '' = discussion ouverte, 'legacy' = fichiers hors discussion
|
||
let FILES_OPEN = false;
|
||
|
||
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde
|
||
// souvent le panneau ouvert, et le rouvrir à chaque rechargement est pénible.
|
||
function toggleFiles(force){
|
||
FILES_OPEN = (force === undefined) ? !FILES_OPEN : !!force;
|
||
document.documentElement.setAttribute('data-files', FILES_OPEN ? '1' : '0');
|
||
const btn = document.getElementById('files-btn');
|
||
if(btn) btn.setAttribute('aria-expanded', FILES_OPEN ? 'true' : 'false');
|
||
try{ localStorage.setItem('loki-files-open', FILES_OPEN ? '1' : '0'); }catch(_){}
|
||
// Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` :
|
||
// la gouttière de barre de défilement mesurée dans --sbw resterait celle de
|
||
// l'ancienne largeur, et la carte de saisie serait décalée du fil. La hauteur,
|
||
// elle, est suivie par un ResizeObserver et n'a besoin de rien.
|
||
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
|
||
if(FILES_OPEN) loadFiles();
|
||
}
|
||
|
||
// Restaure l'état à l'ouverture de la page. Sur petit écran le panneau est un
|
||
// tiroir qui masque toute la conversation : on ne le rouvre jamais tout seul.
|
||
function initFiles(){
|
||
let want = false;
|
||
try{ want = localStorage.getItem('loki-files-open') === '1'; }catch(_){}
|
||
toggleFiles(want && window.innerWidth > 720);
|
||
}
|
||
|
||
async function loadFiles(dir){
|
||
if(dir !== undefined) FILES_DIR = dir;
|
||
const list = document.getElementById('files-list');
|
||
const foot = document.getElementById('files-foot');
|
||
if(!list) return;
|
||
let r;
|
||
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)+'&scope='+encodeURIComponent(FILES_SCOPE)); }
|
||
catch(_){ filesMsg('lecture impossible'); return; }
|
||
if(!r.ok){
|
||
// Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre
|
||
// onglet) ne doit pas laisser le panneau bloqué : on remonte à la racine.
|
||
if(FILES_DIR){ FILES_DIR = ''; return loadFiles(); }
|
||
filesMsg(r.error || 'lecture impossible'); return;
|
||
}
|
||
|
||
renderCrumb(r);
|
||
list.textContent = '';
|
||
if(!r.entries.length){
|
||
const d = document.createElement('div');
|
||
d.className = 'files-empty muted';
|
||
d.textContent = FILES_DIR ? '(dossier vide)'
|
||
: (FILES_SCOPE === 'legacy' ? '(plus rien hors discussion)'
|
||
: "Rien dans cette discussion — les fichiers déposés et ceux que l'agent écrit apparaissent ici.");
|
||
list.appendChild(d);
|
||
}
|
||
for(const e of r.entries) list.appendChild(fileRow(e));
|
||
|
||
renderFoot(foot, r);
|
||
}
|
||
|
||
// Pied : occupation disque de ce qui est affiché — la discussion ouverte, ou le
|
||
// hors-discussion — et le va-et-vient entre les deux. Le bouton n'apparaît que
|
||
// s'il reste vraiment quelque chose hors discussion (compté par le serveur), et
|
||
// disparaît de lui-même une fois le ménage fait.
|
||
function renderFoot(foot, r){
|
||
if(!foot) return;
|
||
foot.textContent = '';
|
||
const n = document.createElement('span');
|
||
n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
|
||
foot.appendChild(n);
|
||
foot.title = r.root || '';
|
||
if(FILES_SCOPE !== 'legacy' && !r.legacy) return;
|
||
const b = document.createElement('button');
|
||
b.className = 'files-scope';
|
||
if(FILES_SCOPE === 'legacy'){
|
||
b.textContent = '← cette discussion';
|
||
b.title = 'revenir aux fichiers de la discussion ouverte';
|
||
b.onclick = ()=>{ FILES_SCOPE = ''; loadFiles(''); };
|
||
} else {
|
||
b.textContent = 'hors discussion (' + r.legacy + ')';
|
||
b.title = "fichiers d'avant le rangement par discussion";
|
||
b.onclick = ()=>{ FILES_SCOPE = 'legacy'; loadFiles(''); };
|
||
}
|
||
foot.appendChild(b);
|
||
}
|
||
|
||
function filesMsg(txt){
|
||
const list = document.getElementById('files-list');
|
||
if(!list) return;
|
||
list.textContent = '';
|
||
const d = document.createElement('div');
|
||
d.className = 'files-empty'; d.style.color = 'var(--err)'; d.textContent = txt;
|
||
list.appendChild(d);
|
||
}
|
||
|
||
// Fil d'Ariane : « discussion / captures », chaque segment cliquable. La racine
|
||
// est le dossier de la discussion ouverte — on n'en sort pas, sauf par le
|
||
// détour « hors discussion », qui a sa propre racine.
|
||
function renderCrumb(r){
|
||
const c = document.getElementById('files-crumb');
|
||
c.textContent = '';
|
||
const seg = (label, dir, last)=>{
|
||
const b = document.createElement('button');
|
||
b.className = 'files-crumb-b' + (last ? ' on' : '');
|
||
b.textContent = label;
|
||
if(!last) b.onclick = ()=>loadFiles(dir);
|
||
else b.disabled = true;
|
||
c.appendChild(b);
|
||
};
|
||
const parts = r.dir ? r.dir.split('/') : [];
|
||
seg(FILES_SCOPE === 'legacy' ? 'hors discussion' : 'discussion', '', parts.length === 0);
|
||
let acc = '';
|
||
parts.forEach((p, i)=>{
|
||
acc = acc ? acc + '/' + p : p;
|
||
const sep = document.createElement('span');
|
||
sep.className = 'files-crumb-s'; sep.textContent = '/';
|
||
c.appendChild(sep);
|
||
seg(p, acc, i === parts.length - 1);
|
||
});
|
||
}
|
||
|
||
function fileRow(e){
|
||
const row = document.createElement('div');
|
||
row.className = 'files-row' + (e.dir ? ' isdir' : '');
|
||
|
||
const ic = document.createElement('span');
|
||
ic.className = 'files-ic';
|
||
ic.textContent = e.dir ? '▸' : (e.image ? '▣' : '·');
|
||
row.appendChild(ic);
|
||
|
||
const mid = document.createElement('span');
|
||
mid.className = 'files-mid';
|
||
const nm = document.createElement('span');
|
||
nm.className = 'files-name';
|
||
nm.textContent = e.name;
|
||
nm.title = e.name;
|
||
const meta = document.createElement('span');
|
||
meta.className = 'files-meta';
|
||
meta.textContent = fmtSize(e.size) + (e.dir && e.items ? ' · ' + e.items + ' fich.' : '') + ' · ' + fmtWhen(e.mod);
|
||
mid.append(nm, meta);
|
||
row.appendChild(mid);
|
||
|
||
// Un dossier s'ouvre ; un fichier se télécharge. Le clic sur la ligne fait
|
||
// l'action évidente, les boutons ne servent qu'au reste.
|
||
if(e.dir){
|
||
mid.onclick = ()=>loadFiles(e.path);
|
||
mid.style.cursor = 'pointer';
|
||
} else {
|
||
mid.onclick = ()=>downloadWorkspaceFile(e.path, e.name, row);
|
||
mid.style.cursor = 'pointer';
|
||
}
|
||
|
||
const acts = document.createElement('span');
|
||
acts.className = 'files-acts';
|
||
if(!e.dir){
|
||
const dl = document.createElement('button');
|
||
dl.className = 'iconbtn'; dl.textContent = '↓'; dl.title = 'télécharger';
|
||
dl.onclick = ev=>{ ev.stopPropagation(); downloadWorkspaceFile(e.path, e.name, row); };
|
||
acts.appendChild(dl);
|
||
}
|
||
const rm = document.createElement('button');
|
||
rm.className = 'iconbtn'; rm.textContent = '🗑'; rm.title = 'supprimer';
|
||
rm.onclick = ev=>{ ev.stopPropagation(); removeFile(e); };
|
||
acts.appendChild(rm);
|
||
row.appendChild(acts);
|
||
return row;
|
||
}
|
||
|
||
// Suppression. Toujours confirmée, et l'invite dit ce qui part : un dossier
|
||
// emporte tout son contenu, ce qu'un nom seul ne laisse pas deviner.
|
||
async function removeFile(e){
|
||
const quoi = e.dir
|
||
? 'Le dossier « '+e.name+' » et tout son contenu ('+(e.items||0)+' fichiers, '+fmtSize(e.size)+') seront supprimés.'
|
||
: '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
|
||
if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
|
||
{title:'Supprimer ?', okText:'Supprimer', danger:true})) return;
|
||
const r = await jpost('/api/chat/file/delete', {path: e.path, scope: FILES_SCOPE});
|
||
if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
|
||
toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
|
||
loadFiles();
|
||
}
|
||
|
||
// Changement de discussion (bascule, « vider »), signalé par le flux SSE :
|
||
// le panneau montre les fichiers de la discussion OUVERTE, il doit donc être
|
||
// redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui
|
||
// de l'autre discussion, il n'existe pas forcément ici.
|
||
function filesOnConvChange(){
|
||
FILES_DIR = ''; FILES_SCOPE = '';
|
||
if(FILES_OPEN) loadFiles('');
|
||
}
|
||
|
||
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
|
||
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
|
||
document.addEventListener('DOMContentLoaded', initFiles);
|
||
|
||
function fmtWhen(sec){
|
||
if(!sec) return '';
|
||
const d = new Date(sec*1000), now = new Date();
|
||
const memeJour = d.toDateString() === now.toDateString();
|
||
return memeJour
|
||
? d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'})
|
||
: d.toLocaleDateString(undefined, {day:'2-digit', month:'2-digit'});
|
||
}
|
||
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
|
||
//
|
||
// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils :
|
||
// basculer ici bascule partout. Le changement arrive donc par le flux SSE (un
|
||
// événement `reset`, comme pour « vider »), et c'est lui qui redessine le chat —
|
||
// on ne fait que rafraîchir la liste.
|
||
//
|
||
// Les titres viennent du premier message de l'utilisateur : ils sont construits
|
||
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
|
||
// balisage ni les gestionnaires d'événement.
|
||
|
||
async function loadConversations(){
|
||
let r;
|
||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||
const cont = document.getElementById('conv-list');
|
||
if(!cont) return;
|
||
cont.innerHTML = '';
|
||
const list = r.conversations || [];
|
||
if(!list.length){ cont.innerHTML = '<span class="muted">(aucune)</span>'; return; }
|
||
for(const c of list){
|
||
const row = document.createElement('div');
|
||
row.className = 'preset' + (c.id === r.active ? ' active' : '');
|
||
row.onclick = () => convSwitch(c.id);
|
||
|
||
const info = document.createElement('div'); info.className = 'preset-info';
|
||
const nm = document.createElement('div'); nm.className = 'preset-name';
|
||
if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); }
|
||
const title = c.title || 'Nouvelle discussion';
|
||
nm.appendChild(document.createTextNode(title));
|
||
nm.title = title;
|
||
const meta = document.createElement('div'); meta.className = 'preset-meta';
|
||
const when = document.createElement('span'); when.className = 'muted';
|
||
when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
|
||
meta.appendChild(when);
|
||
info.appendChild(nm); info.appendChild(meta);
|
||
|
||
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
|
||
const acts = document.createElement('div'); acts.className = 'conv-acts';
|
||
const ren = document.createElement('button'); ren.className = 'iconbtn'; ren.textContent = '✎';
|
||
ren.title = 'renommer'; ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
|
||
const del = document.createElement('button'); del.className = 'iconbtn'; del.textContent = '🗑';
|
||
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
|
||
acts.appendChild(ren); acts.appendChild(del);
|
||
|
||
row.appendChild(info); row.appendChild(acts);
|
||
cont.appendChild(row);
|
||
}
|
||
}
|
||
|
||
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
|
||
// discussions se lit d'un coup d'œil, pas au timestamp complet.
|
||
function convWhen(unix){
|
||
if(!unix) return '';
|
||
const d = new Date(unix * 1000), now = new Date();
|
||
const sameDay = d.toDateString() === now.toDateString();
|
||
return sameDay
|
||
? d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})
|
||
: d.toLocaleDateString([], {day:'2-digit', month:'2-digit'});
|
||
}
|
||
|
||
async function convNew(){
|
||
try{
|
||
await jpost('/api/conversations/new', {});
|
||
toast('nouvelle discussion');
|
||
loadConversations();
|
||
}catch(_){ toast('erreur réseau'); }
|
||
}
|
||
|
||
async function convSwitch(id){
|
||
try{
|
||
const r = await jpost('/api/conversations/switch', {id});
|
||
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||
loadConversations();
|
||
}catch(_){ toast('erreur réseau'); }
|
||
}
|
||
|
||
async function convRename(id, current){
|
||
const t = await askPrompt('Nouveau titre de la discussion', {title:'Renommer', default:current, okText:'Renommer'});
|
||
if(!t) return;
|
||
try{
|
||
const r = await jpost('/api/conversations/rename', {id, title:t});
|
||
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||
loadConversations();
|
||
}catch(_){ toast('erreur réseau'); }
|
||
}
|
||
|
||
async function convDelete(id, title){
|
||
// Le geste emporte aussi les fichiers de la discussion : ils lui appartiennent
|
||
// (un dossier par discussion côté serveur), autant le dire avant.
|
||
if(!await askConfirm('Supprimer « ' + title + ' » ? Son historique ET ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront perdus.',
|
||
{title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
|
||
try{
|
||
const r = await jpost('/api/conversations/delete', {id});
|
||
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||
loadConversations();
|
||
}catch(_){ toast('erreur réseau'); }
|
||
}
|
||
// Identité — prénom et avatars affichés en tête des bulles du chat.
|
||
//
|
||
// Les valeurs vivent dans les préférences SERVEUR (comme le thème) : elles
|
||
// suivent donc d'un appareil à l'autre, au lieu de rester dans le localStorage
|
||
// d'un seul navigateur. La liste d'emojis est celle que sert le serveur — pas
|
||
// une seconde copie côté client, qui divergerait à la première modification.
|
||
|
||
let IDENT = {name:'', user:'🙂', ai:'🦊'};
|
||
let AVATARS = [];
|
||
|
||
// applyIdentity redessine les libellés des bulles DÉJÀ affichées : changer son
|
||
// prénom doit se voir tout de suite, sans recharger la page.
|
||
function applyIdentity(){
|
||
document.querySelectorAll('#chat .msg.user > .label, #chat .msg.assistant > .label')
|
||
.forEach(l => paintLabel(l, l.parentElement.classList.contains('user') ? 'user' : 'assistant'));
|
||
}
|
||
|
||
// paintLabel remplit un libellé : avatar + nom. Le prénom vient de
|
||
// l'utilisateur, donc textContent — jamais innerHTML.
|
||
function paintLabel(label, role){
|
||
if(!label) return;
|
||
label.textContent = '';
|
||
const av = document.createElement('i'); av.className = 'av';
|
||
av.textContent = role === 'user' ? (IDENT.user || '🙂') : (IDENT.ai || '🦊');
|
||
const who = document.createElement('span'); who.className = 'who';
|
||
who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki';
|
||
label.appendChild(av); label.appendChild(who);
|
||
}
|
||
|
||
async function loadIdentity(){
|
||
let r;
|
||
try{ r = await jget('/api/prefs'); }catch(_){ return; }
|
||
const p = r.prefs || {};
|
||
AVATARS = r.avatars || [];
|
||
IDENT = {
|
||
name: p.user_name || '',
|
||
user: p.avatar_user || '🙂',
|
||
ai: p.avatar_ai || '🦊',
|
||
};
|
||
const inp = document.getElementById('p-username');
|
||
if(inp && document.activeElement !== inp) inp.value = IDENT.name;
|
||
renderAvatarPicker('p-avatar-user', 'user');
|
||
renderAvatarPicker('p-avatar-ai', 'ai');
|
||
applyIdentity();
|
||
}
|
||
|
||
function renderAvatarPicker(elID, which){
|
||
const box = document.getElementById(elID);
|
||
if(!box) return;
|
||
box.innerHTML = '';
|
||
for(const e of AVATARS){
|
||
const b = document.createElement('button');
|
||
b.type = 'button'; b.textContent = e;
|
||
if(e === IDENT[which]) b.classList.add('sel');
|
||
b.onclick = () => pickAvatar(which, e);
|
||
box.appendChild(b);
|
||
}
|
||
}
|
||
|
||
async function pickAvatar(which, emoji){
|
||
IDENT[which] = emoji;
|
||
renderAvatarPicker(which === 'user' ? 'p-avatar-user' : 'p-avatar-ai', which);
|
||
applyIdentity();
|
||
try{ await jpost('/api/prefs', which === 'user' ? {avatar_user:emoji} : {avatar_ai:emoji}); }
|
||
catch(_){ toast('erreur réseau'); }
|
||
}
|
||
|
||
// Saisie du prénom : on n'écrit pas au serveur à chaque frappe.
|
||
let identTimer = null;
|
||
function saveIdentity(){
|
||
const inp = document.getElementById('p-username');
|
||
if(!inp) return;
|
||
IDENT.name = inp.value.trim();
|
||
applyIdentity();
|
||
clearTimeout(identTimer);
|
||
identTimer = setTimeout(async () => {
|
||
try{ await jpost('/api/prefs', {user_name: IDENT.name}); }catch(_){ toast('erreur réseau'); }
|
||
}, 500);
|
||
}
|
||
</script></body></html>
|