mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 12:16:21 +02:00
Rebaptise AJEAN en Loki (fork, lignée conservée)
- module github.com/R0m1k3/Loki, cmd/loki, internal/loki (package loki) - LOKI_HOME, LOKI_MODEL_DIRS, LOKI_SERVICE, LOKI_DL_CONNS ; /etc/loki ; units loki-engine / loki-ui ; binaire et aide CLI - updateRepo pointe sur R0m1k3/Loki (l'auto-update ne tirera plus les binaires AJEAN amont) Conservé à l'identique : le domaine ajean.link (service de tunnel amont), les littéraux de migration 0.7.x (migrate_07.go), RELEASE_NOTES.md et LICENSE (historique et licence de l'amont). go build/vet/test : verts.
This commit is contained in:
157 files changed
+928
-928
No files matched your search
@@ -0,0 +1,813 @@
|
||||
<!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>
|
||||
@@CSS@@
|
||||
</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">
|
||||
<div class="sidehead">
|
||||
<h1 id="brand" aria-label="loki"><span id="brand-a"><svg viewBox="0 0 4 5" shape-rendering="crispEdges" fill="currentColor"><rect x="1" y="0" width="2" height="1"/><rect x="0" y="1" width="1" height="1"/><rect x="3" y="1" width="1" height="1"/><rect x="0" y="2" width="4" height="1"/><rect x="0" y="3" width="1" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="0" y="4" width="1" height="1"/><rect x="3" y="4" width="1" height="1"/></svg></span><span id="brand-word"><svg viewBox="0 0 17 5" shape-rendering="crispEdges" fill="currentColor"><rect x="1" y="0" width="1" height="1"/><rect x="1" y="1" width="1" height="1"/><rect x="1" y="2" width="1" height="1"/><rect x="1" y="3" width="1" height="1"/><rect x="0" y="4" width="1" height="1"/><rect x="3" y="0" width="4" height="1"/><rect x="3" y="1" width="1" height="1"/><rect x="3" y="2" width="3" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="3" y="4" width="4" height="1"/><rect x="9" y="0" width="2" height="1"/><rect x="8" y="1" width="1" height="1"/><rect x="11" y="1" width="1" height="1"/><rect x="8" y="2" width="4" height="1"/><rect x="8" y="3" width="1" height="1"/><rect x="11" y="3" width="1" height="1"/><rect x="8" y="4" width="1" height="1"/><rect x="11" y="4" width="1" height="1"/><rect x="13" y="0" width="1" height="1"/><rect x="16" y="0" width="1" height="1"/><rect x="13" y="1" width="2" height="1"/><rect x="16" y="1" width="1" height="1"/><rect x="13" y="2" width="1" height="1"/><rect x="15" y="2" width="2" height="1"/><rect x="13" y="3" width="1" height="1"/><rect x="16" y="3" width="1" height="1"/><rect x="13" y="4" width="1" height="1"/><rect x="16" y="4" width="1" height="1"/></svg></span><span id="ver"></span></h1>
|
||||
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
||||
</div>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état.
|
||||
C'est le seul endroit où l'on voit pourquoi un modèle refuse de charger. -->
|
||||
<div id="svc-log-box" style="display:none;margin:0 0 10px">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<span class="io-cap">Journal du moteur</span>
|
||||
<button onclick="loadSvcLog()" title="rafraîchir">↻</button>
|
||||
</div>
|
||||
<pre id="svc-log" class="muted" style="font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:6px 0 0"></pre>
|
||||
<!-- Emplacements : au même endroit que le journal, c'est le panneau où l'on
|
||||
va quand on cherche à comprendre l'état de son installation. -->
|
||||
<div class="row" style="justify-content:space-between;align-items:center;margin-top:12px">
|
||||
<span class="io-cap">Emplacements</span>
|
||||
</div>
|
||||
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
||||
</div>
|
||||
<!-- GPU et RAM décrivent la même chose (les ressources de la machine) :
|
||||
une seule section, deux blocs de statistique dans la même carte. -->
|
||||
<details open><summary><span class="stitle">Machine</span></summary>
|
||||
<div class="slist">
|
||||
<div id="vram">…</div>
|
||||
<div id="ram-details"><div id="ram"></div></div>
|
||||
</div>
|
||||
</details>
|
||||
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
|
||||
<div id="cfg" class="slist">…</div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter">+</button></summary>
|
||||
<div 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">Apparence</span></summary>
|
||||
<div class="slist">
|
||||
<label class="switchrow rev"><span>mode sombre</span>
|
||||
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="subhead">Affichage</div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-reasoning" onchange="toggleView('hide-reasoning')"><span class="slider"></span></span> masquer le raisonnement</label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-tools" onchange="toggleView('hide-tools')"><span class="slider"></span></span> masquer les appels d'outils</label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-side" onchange="toggleView('hide-side')"><span class="slider"></span></span> barre latérale escamotable<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">La barre latérale devient un tiroir <b>même sur ordinateur</b> : elle reste fermée et s'ouvre avec le bouton ☰. Toute la largeur pour la conversation.</span></span></label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
||||
</div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">System prompt</span></summary>
|
||||
<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><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="remote-details"><summary><span class="stitle">Accès distant</span><span id="remote-badge" class="sbadge"></span></summary>
|
||||
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur Loki depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
|
||||
<!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). -->
|
||||
<div id="remote-off" class="slist"><div class="rowbody">
|
||||
<p class="muted" style="margin:0 0 8px">Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.</p>
|
||||
<button onclick="remoteConnect()" style="border-color:var(--accent);color:var(--accent)">Connecter à ajean.link</button>
|
||||
</div></div>
|
||||
<!-- Connecté : URL publique + appairage + déconnexion. Le bouton « démarrer
|
||||
le tunnel » vit DANS la carte de l'adresse : c'est l'action de cette
|
||||
ligne, il flottait auparavant entre deux cartes. -->
|
||||
<div id="remote-on" style="display:none">
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div class="muted" style="margin:0 0 6px">Votre adresse d'accès distant</div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="remote-url" readonly class="io-input">
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('remote-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>
|
||||
<button class="iconbtn" onclick="window.open(document.getElementById('remote-url').value,'_blank')" title="ouvrir"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/></svg></button>
|
||||
</div>
|
||||
<div id="remote-status" class="muted" style="margin-top:6px"></div>
|
||||
<div class="row" style="margin-bottom:0"><button id="remote-start" onclick="remoteStart()" style="display:none;border-color:var(--accent);color:var(--accent)">démarrer le tunnel</button></div>
|
||||
</div></div>
|
||||
<div class="subhead">Première connexion<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">À votre première ouverture du portail distant, confirmez l'<b>empreinte</b> et saisissez le <b>code d'appairage</b> : cela verrouille la boîte noire sur CE serveur (anti-interception). Une seule fois par appareil.</span></span></div>
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div class="row" style="margin:0"><button onclick="remotePairCode()">Afficher l'empreinte et le code d'appairage</button></div>
|
||||
<div id="remote-pair" class="muted" style="display:none;margin-top:6px"></div>
|
||||
<div class="row" style="margin-bottom:0"><button onclick="remoteDisconnect()" class="btn-danger-o">déconnecter l'accès distant</button></div>
|
||||
</div></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">
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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>
|
||||
<!-- Postes distants : l'icône dit SUR QUOI l'IA agit. Écran = ce serveur
|
||||
(cible locale) ; ondes wifi = un poste distant est la cible active.
|
||||
Un clic ouvre la modale de gestion (cible, liste, appairage). -->
|
||||
<button id="node-btn" onclick="openNodeHub()" title="Postes distants" aria-label="Postes distants">
|
||||
<svg class="ic-here" 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"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
<svg class="ic-remote" 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="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.55a11 11 0 0 1 14 0"/><path d="M8.5 16.1a6 6 0 0 1 7 0"/><path d="M12 20h.01"/></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>
|
||||
<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="node-hub-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-hub-modal')">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<strong>Postes distants</strong>
|
||||
<button class="modal-x" onclick="hideModal('node-hub-modal')" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="muted" style="font-size:12px;line-height:1.5;margin-bottom:10px">
|
||||
Pilote un <b>autre PC</b> depuis l'IA. Choisis sur quelle machine l'IA agit
|
||||
(ses outils shell/fichiers) et appaire de nouveaux postes. Tu décides des
|
||||
capacités et du dossier autorisé pour chaque poste.
|
||||
</div>
|
||||
<div id="node-hub-list" class="mcp-list"></div>
|
||||
<button class="addbtn" style="margin-top:10px" onclick="openNodePair()">+ appairer un poste</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="node-pair-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-pair-modal')">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<strong>Appairer un poste distant</strong>
|
||||
<button class="modal-x" onclick="hideModal('node-pair-modal')" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="fld">
|
||||
<label class="fld-l">Capacités autorisées <span class="opt">(ce que l'IA pourra faire sur ce poste)</span></label>
|
||||
<div id="node-pair-caps" class="node-caps"></div>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label class="fld-l" for="node-pair-root">Dossier autorisé <span class="opt">(confine lecture/écriture ; laisser vide = dossier de travail par défaut du poste)</span></label>
|
||||
<input id="node-pair-root" class="ctl" placeholder="C:\\Users\\moi\\loki ou /home/moi/loki" spellcheck="false">
|
||||
</div>
|
||||
<div id="node-pair-out" style="display:none">
|
||||
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
|
||||
<div class="node-code" id="node-pair-code">——</div>
|
||||
<div class="muted" style="font-size:12px;line-height:1.5">
|
||||
Sur le PC à piloter, installe <b>loki</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
|
||||
<pre class="node-cmd" id="node-pair-cmd"></pre>
|
||||
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
|
||||
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
|
||||
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<div class="foot-l"></div>
|
||||
<div class="foot-r">
|
||||
<button onclick="hideModal('node-pair-modal')">fermer</button>
|
||||
<button class="btn-save" onclick="genNodeCode()">générer le code</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>
|
||||
<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>
|
||||
<div class="pe-seg">
|
||||
<label><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Précompilé</span><span class="be-note" id="be-fast-note"></span></label>
|
||||
<label><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Compilé</span><span class="be-note" id="be-opt-note"></span></label>
|
||||
<label><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
|
||||
</div>
|
||||
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Backend</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="m-backend-detected" onchange="onPickDetected()"></select></span></span>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<input id="m-backend-path" class="pe-inp" oninput="onCustomPath()" placeholder="ou un chemin vers llama-server(.exe)">
|
||||
<div class="pe-note" id="be-drop-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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</span></span>
|
||||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="number" min="0" step="1" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">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>
|
||||
@@JS@@
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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){}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// ===== 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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// ===== 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(); });
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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); }
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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(); }
|
||||
@@ -0,0 +1,175 @@
|
||||
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 — replié par défaut, on l'ouvre en cliquant la pastille.
|
||||
function toggleSvcLog(){
|
||||
const box=document.getElementById('svc-log-box');
|
||||
if(!box) return;
|
||||
const show = box.style.display==='none';
|
||||
box.style.display = show ? '' : 'none';
|
||||
if(show){ loadSvcLog(); showPaths(); }
|
||||
}
|
||||
async function loadSvcLog(){
|
||||
const el=document.getElementById('svc-log');
|
||||
if(!el) return;
|
||||
el.textContent='chargement du journal…';
|
||||
try{
|
||||
const r=await jget('/api/service/log?n=120');
|
||||
el.textContent = (r && r.log && r.log.trim()) ? r.log : 'journal vide — le moteur n\'a encore rien écrit.';
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}catch(e){ el.textContent='journal indisponible : '+e; }
|
||||
}
|
||||
async function checkUpdate(){
|
||||
const b=document.getElementById('upd-check'), msg=document.getElementById('upd-msg');
|
||||
b.disabled=true; msg.textContent='Vérification…';
|
||||
try{
|
||||
const r=await jget('/api/update');
|
||||
if(r.error){ msg.textContent='Erreur : '+r.error; }
|
||||
else if(r.available){
|
||||
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
|
||||
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
|
||||
msg.appendChild(btn);
|
||||
} else { msg.textContent='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;
|
||||
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('');
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
// --- 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 (accès distant coupé,
|
||||
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||
// hauteurs réservées en place pour toujours.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
||||
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); }
|
||||
@@ -0,0 +1,985 @@
|
||||
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();
|
||||
// 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 = '';
|
||||
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 || '';
|
||||
// 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';
|
||||
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 === '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;
|
||||
}
|
||||
async function startDownload(){
|
||||
const url = document.getElementById('m-hf-url').value.trim();
|
||||
if(!url){ toast('colle un lien .gguf'); return; }
|
||||
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;
|
||||
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; return;
|
||||
}
|
||||
watchDownload(r.filename);
|
||||
}
|
||||
// Annule le téléchargement en cours : le serveur coupe les connexions et
|
||||
// supprime le fichier partiel.
|
||||
async function cancelDownload(){
|
||||
if(!dlName) return;
|
||||
const e = dlEls();
|
||||
e.cancel.disabled = true;
|
||||
await jpost('/api/models/download/cancel', {filename: dlName});
|
||||
// L'état « annulé » est confirmé par le prochain tick de poll.
|
||||
}
|
||||
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
|
||||
function watchDownload(fname){
|
||||
const e = dlEls();
|
||||
dlName = fname;
|
||||
stopDlPoll();
|
||||
e.btn.disabled = true;
|
||||
e.prog.style.display = 'block';
|
||||
e.bar.style.display = 'block';
|
||||
e.cancel.style.display = 'inline';
|
||||
e.cancel.disabled = false;
|
||||
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
|
||||
const tick = async ()=>{
|
||||
const list = await jget('/api/models/download/status');
|
||||
const st = (list||[]).find(d=>d.filename===fname);
|
||||
if(!st) return;
|
||||
if(st.canceled){
|
||||
e.prog.textContent = 'téléchargement annulé — '+fname;
|
||||
e.bar.style.display = 'none'; stop(); return;
|
||||
}
|
||||
if(st.error){
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
|
||||
e.bar.style.display = 'none'; stop(); return;
|
||||
}
|
||||
if(st.finished){
|
||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
||||
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||||
stop();
|
||||
await 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);
|
||||
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);
|
||||
}
|
||||
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
|
||||
// up. Re-stick when they scroll back near bottom themselves.
|
||||
@@ -0,0 +1,356 @@
|
||||
let stickyBottom = true;
|
||||
const chatEl = () => document.getElementById('chat');
|
||||
function isNearBottom(){
|
||||
const c = chatEl();
|
||||
return c.scrollHeight - c.scrollTop - c.clientHeight < 60;
|
||||
}
|
||||
// La zone de chat réserve une gouttière de barre de défilement de chaque côté
|
||||
// (scrollbar-gutter: stable both-edges) ; le composer, lui, n'est pas défilant.
|
||||
// On mesure la gouttière réelle et on la reporte sur le composer, sinon les
|
||||
// messages sont en retrait par rapport à la zone de saisie — décalage visible
|
||||
// surtout quand la barre latérale est escamotée.
|
||||
function syncGutter(){
|
||||
const chat=document.getElementById('chat'); if(!chat) return;
|
||||
const g=Math.max(0,(chat.offsetWidth-chat.clientWidth)/2);
|
||||
document.documentElement.style.setProperty('--sbw', g+'px');
|
||||
}
|
||||
addEventListener('resize', syncGutter);
|
||||
addEventListener('DOMContentLoaded', syncGutter);
|
||||
|
||||
function scrollMaybe(){
|
||||
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
|
||||
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
|
||||
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
|
||||
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
|
||||
if(REPLAYING) return;
|
||||
if(stickyBottom){
|
||||
const c = chatEl();
|
||||
c.scrollTop = c.scrollHeight;
|
||||
}
|
||||
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
|
||||
}
|
||||
function jumpBottom(){ stickyBottom = true; scrollMaybe(); }
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const c = chatEl();
|
||||
c.addEventListener('scroll', ()=>{
|
||||
stickyBottom = isNearBottom();
|
||||
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
|
||||
});
|
||||
});
|
||||
|
||||
function addMsg(role, text){
|
||||
const el=document.createElement('div');
|
||||
el.className='msg '+role;
|
||||
const collapsible = (role==='reasoning' || role==='tool');
|
||||
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
|
||||
if(collapsible){
|
||||
el.classList.add('collapsible');
|
||||
el.innerHTML='<span class="label">'+role+'</span><div class="bodywrap"><div class="body"></div></div>';
|
||||
el.querySelector('.label').onclick=()=>toggleCollapse(el);
|
||||
} else {
|
||||
el.innerHTML='<span class="label">'+role+'</span><div class="body"></div>';
|
||||
}
|
||||
el.querySelector('.body').textContent=text;
|
||||
chatEl().appendChild(el);
|
||||
scrollMaybe();
|
||||
return el;
|
||||
}
|
||||
// Bulle « … » animée affichée dès l'envoi, retirée au 1er token/outil/erreur.
|
||||
function addTyping(){
|
||||
const el=document.createElement('div');
|
||||
el.className='msg assistant typing';
|
||||
el.innerHTML='<span></span><span></span><span></span>';
|
||||
chatEl().appendChild(el); scrollMaybe();
|
||||
return el;
|
||||
}
|
||||
// Replie/déplie en douceur les bulles reasoning/tool. Hauteur animée en JS :
|
||||
// on fige scrollHeight puis on va à 0 (fermeture) ou de 0 vers scrollHeight
|
||||
// (ouverture), sans jamais dépasser. overflow:hidden clippe pendant l'animation.
|
||||
function collapseBody(el){
|
||||
const bw=el.querySelector('.bodywrap'); if(!bw || el.classList.contains('collapsed')) return;
|
||||
bw.style.height = bw.scrollHeight+'px'; // fige les dimensions courantes
|
||||
bw.style.width = bw.scrollWidth+'px';
|
||||
void bw.offsetHeight; // reflow pour que la transition parte de là
|
||||
el.classList.add('collapsed');
|
||||
bw.style.height = '0px'; // → anime height ET width vers 0
|
||||
bw.style.width = '0px';
|
||||
}
|
||||
function expandBody(el){
|
||||
const bw=el.querySelector('.bodywrap'); if(!bw) return;
|
||||
el.classList.remove('collapsed');
|
||||
bw.style.height=''; bw.style.width=''; // mesure les dimensions naturelles…
|
||||
const h=bw.scrollHeight, w=bw.scrollWidth;
|
||||
bw.style.height='0px'; bw.style.width='0px';// …repart de 0 (pas de flash, même frame)
|
||||
void bw.offsetHeight;
|
||||
bw.style.height=h+'px'; bw.style.width=w+'px';
|
||||
const done=e=>{ if(e.propertyName!=='height') return; bw.style.height=''; bw.style.width=''; bw.removeEventListener('transitionend',done); };
|
||||
bw.addEventListener('transitionend',done);
|
||||
}
|
||||
function toggleCollapse(el){ el.classList.contains('collapsed') ? expandBody(el) : collapseBody(el); }
|
||||
// Replie toutes les bulles d'un tour une fois la réponse finale entamée.
|
||||
function collapseAll(list){ for(const el of list){ if(el) collapseBody(el); } list.length=0; }
|
||||
// Replie une bulle INSTANTANÉMENT (sans animation) — utilisé pendant le replay au
|
||||
// chargement pour que les vieilles bulles apparaissent déjà fermées. La classe
|
||||
// 'collapsed' seule ne gère que l'opacité ; la hauteur est en style inline, donc
|
||||
// on la met à 0 transition désactivée.
|
||||
function collapseInstant(el){
|
||||
const bw=el.querySelector('.bodywrap'); if(!bw) return;
|
||||
el.classList.add('collapsed');
|
||||
// Pas de `void bw.offsetHeight` ici : la bulle vient d'être créée et n'a jamais
|
||||
// été peinte dépliée, donc poser height:0 n'anime pas — inutile de forcer un
|
||||
// reflow par bulle (ce qui, multiplié par le replay, coûtait très cher).
|
||||
bw.style.transition='none';
|
||||
bw.style.height='0px'; bw.style.width='0px';
|
||||
requestAnimationFrame(()=>{ bw.style.transition=''; });
|
||||
}
|
||||
function setLabel(el, text){ el.querySelector('.label').textContent = text; }
|
||||
// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible
|
||||
// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture
|
||||
// survit au repli, donc on le met là plutôt que dans le corps seul.
|
||||
function setLabelCounts(el, add, del){
|
||||
const lab=el.querySelector('.label');
|
||||
const cnt=document.createElement('span'); cnt.className='diff-count';
|
||||
if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add}));
|
||||
if(add && del) cnt.appendChild(document.createTextNode(' '));
|
||||
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
|
||||
lab.appendChild(cnt);
|
||||
}
|
||||
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/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); scrollMaybe(); }
|
||||
// Render a tool call as its own conversation message: the command the model
|
||||
// wrote, then the response it got back. textContent keeps it injection-safe.
|
||||
function renderToolMsg(el, tu){
|
||||
// Métadonnées d'affichage par outil : nom court + en-tête. Les outils web
|
||||
// (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire.
|
||||
const META = {
|
||||
bash: {lbl:'terminal', head:'commande'},
|
||||
write: {lbl:'fichier', head:'écriture'},
|
||||
edit: {lbl:'édition', head:'édition'},
|
||||
web_search: {lbl:'recherche', head:'recherche web'},
|
||||
web_open: {lbl:'page web', head:'ouverture'},
|
||||
web_read: {lbl:'page web', head:'lecture'},
|
||||
web_grep: {lbl:'page web', head:'recherche'},
|
||||
};
|
||||
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
|
||||
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
|
||||
let meta = META[tu.name];
|
||||
if(!meta && tu.name && tu.name.indexOf('mcp__')===0){
|
||||
const parts = tu.name.slice(5).split('__');
|
||||
const server = parts.shift() || 'mcp';
|
||||
const tool = parts.join('__') || tu.name;
|
||||
meta = {lbl: tool, head: server};
|
||||
}
|
||||
meta = meta || {lbl:'mémoire', head:'mémoire'};
|
||||
let lbl = meta.lbl;
|
||||
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
|
||||
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
|
||||
setLabel(el, lbl);
|
||||
// Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
|
||||
// reporté sur l'étiquette, pour rester lisible bulle repliée.
|
||||
let add=0, del=0;
|
||||
if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
|
||||
else if(tu.body){ add=tu.body.split('\n').length; }
|
||||
if(add||del) setLabelCounts(el, add, del);
|
||||
const body=bodyOf(el); body.innerHTML='';
|
||||
const head=document.createElement('div'); head.className='tool-head';
|
||||
head.textContent = meta.head;
|
||||
body.appendChild(head);
|
||||
if(tu.label){
|
||||
const pre=document.createElement('pre'); pre.className='tool-cmd';
|
||||
const code=document.createElement('code'); code.textContent=tu.label;
|
||||
if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
}
|
||||
// Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à
|
||||
// ligne, dans la même forme que le diff final, pour que la bulle se remplisse
|
||||
// sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la
|
||||
// dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement
|
||||
// ferait clignoter le bloc entier.
|
||||
if(tu.body && !(tu.diff && tu.diff.length)){
|
||||
const lines=tu.body.split('\n');
|
||||
const sub=document.createElement('div'); sub.className='tool-sub';
|
||||
sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée)
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre'); pre.className='diff live';
|
||||
lines.forEach((t,i)=>{
|
||||
const ln=document.createElement('span');
|
||||
ln.className='dl add'+(i===lines.length-1?' fresh':'');
|
||||
ln.textContent='+ '+t;
|
||||
if(i===lines.length-1 && tu.typing){
|
||||
const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋';
|
||||
ln.appendChild(car);
|
||||
}
|
||||
pre.appendChild(ln);
|
||||
});
|
||||
body.appendChild(pre);
|
||||
// Le bloc est re-créé à chaque événement : on le recale en bas pour suivre
|
||||
// la ligne en cours (max-height côté CSS l'empêche de pousser le fil).
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
|
||||
// retirées en rouge, contexte en gris — comme un diff de terminal.
|
||||
if(tu.diff && tu.diff.length){
|
||||
const sub=document.createElement('div'); sub.className='tool-sub';
|
||||
sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée)
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre'); pre.className='diff';
|
||||
tu.diff.forEach(l=>{
|
||||
const ln=document.createElement('span');
|
||||
ln.className='dl'+(l.op==='+'?' add':l.op==='-'?' del':'');
|
||||
ln.textContent=(l.op==='+'?'+':l.op==='-'?'-':' ')+' '+l.text;
|
||||
pre.appendChild(ln);
|
||||
});
|
||||
body.appendChild(pre);
|
||||
}
|
||||
const hasResult = tu.result!==undefined && tu.result!=='';
|
||||
if(hasResult){
|
||||
const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre');
|
||||
const code=document.createElement('code'); code.textContent=tu.result;
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
} else if(!tu.done && !tu.typing){
|
||||
const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…';
|
||||
body.appendChild(wait);
|
||||
}
|
||||
addCopyButtons(body); scrollMaybe();
|
||||
}
|
||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
root.querySelectorAll('pre').forEach(pre=>{
|
||||
if(pre.querySelector('.copybtn')) return;
|
||||
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
|
||||
if(pre.classList.contains('diff')) return;
|
||||
const btn=document.createElement('button');
|
||||
btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
|
||||
btn.onclick=async(e)=>{
|
||||
e.stopPropagation();
|
||||
const code=pre.querySelector('code'), txt=(code||pre).innerText;
|
||||
try{ await navigator.clipboard.writeText(txt); }
|
||||
catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
|
||||
btn.textContent='copié ✓'; btn.classList.add('done');
|
||||
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
|
||||
};
|
||||
pre.appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Nouvelle conversation POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||||
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
|
||||
function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); toast('nouvelle conversation'); }
|
||||
// Compaction : on demande à l'IA un résumé de la conversation destiné à la
|
||||
// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
|
||||
// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
|
||||
// Compaction MANUELLE : le compactage est automatique (façon Hermes) quand le
|
||||
// contexte se remplit, mais ce bouton permet de le déclencher à la demande. Le
|
||||
// serveur possède la conversation : on lance la compaction côté serveur et la
|
||||
// progression (bannière « compactage en cours », résultat) arrive par le flux
|
||||
// d'abonnement, comme pour la génération — donc visible sur tous les appareils.
|
||||
async function compactContext(){
|
||||
if(!await askConfirm('Résumer les anciens tours pour libérer du contexte ? La conversation continue normalement.', {title:'Compacter le contexte', okText:'Compacter'})) return;
|
||||
const btn=document.getElementById('ctx-compact'); btn.disabled=true;
|
||||
try{
|
||||
const r=await jfetch('/api/chat/compact',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(!j.ok) toast(j.error||'compaction indisponible');
|
||||
}catch(e){ toast('erreur : '+(e.message||e)); }
|
||||
btn.disabled=false;
|
||||
}
|
||||
// Persistance de la conversation : on garde user+assistant en localStorage pour
|
||||
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
|
||||
function saveChat(){ try{ localStorage.setItem('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';
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// ===== 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; }
|
||||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); 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; }
|
||||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
|
||||
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
||||
// donc une notification persistée se re-déclenchait à chaque rafraîchissement
|
||||
// (« rien à compacter » qui revient sans raison). C'est un événement ponctuel,
|
||||
// il n'a de sens qu'en direct — contrairement à la marque `compacted`, qui est
|
||||
// une trace du fil et DOIT être rejouée.
|
||||
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
|
||||
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
|
||||
if(d.stats){ T.serverStats=d.stats;
|
||||
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
|
||||
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
|
||||
if(d.tool_used){
|
||||
killTyping('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);
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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'); }
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// ─── 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é');
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
|
||||
// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
|
||||
// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
|
||||
// l'agent local (/api/link/connect) qui fait le `loki link` (token + service).
|
||||
|
||||
const LOKI_APP_ORIGIN = 'https://app.ajean.link';
|
||||
|
||||
function renderRemote(d){
|
||||
const off = document.getElementById('remote-off'), on = document.getElementById('remote-on');
|
||||
const badge = document.getElementById('remote-badge');
|
||||
if(!d || !d.linked){
|
||||
// Serveur jamais lié : on l'affiche explicitement plutôt que de laisser la
|
||||
// pastille vide, qui se lisait comme « je ne sais pas ».
|
||||
off.style.display=''; on.style.display='none';
|
||||
setBadge(badge, false, 'non connecté');
|
||||
return;
|
||||
}
|
||||
off.style.display='none'; on.style.display='';
|
||||
document.getElementById('remote-url').value = d.machineURL || '';
|
||||
const st = document.getElementById('remote-status');
|
||||
if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; }
|
||||
else { st.textContent='○ tunnel arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; }
|
||||
const sb = document.getElementById('remote-start');
|
||||
if(sb){ sb.style.display = d.active ? 'none' : ''; }
|
||||
setBadge(badge, d.active ? true : 'warn', d.active ? 'connecté' : 'arrêté');
|
||||
}
|
||||
|
||||
async function loadRemote(){
|
||||
// Le panneau ne vaut qu'en LOCAL : il pilote /api/link/* que le tunnel bloque
|
||||
// (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache.
|
||||
const det = document.getElementById('remote-details');
|
||||
if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; }
|
||||
// Statut injoignable : on retombe sur « non connecté » plutôt que de garder
|
||||
// l'affichage précédent, qui pourrait annoncer « connecté » à tort.
|
||||
try{ renderRemote(await jget('/api/link/status')); }catch(e){ renderRemote(null); }
|
||||
}
|
||||
|
||||
// Ouvre la popup de connexion et attend la clé renvoyée par postMessage.
|
||||
function remoteConnect(){
|
||||
const params = new URLSearchParams({ origin: window.location.origin, host: window.location.hostname || 'ce serveur' });
|
||||
const url = LOKI_APP_ORIGIN + '/connect.html?' + params.toString();
|
||||
const pop = window.open(url, 'loki-connect', 'width=440,height=640');
|
||||
if(!pop){ toast('autorisez les popups pour connecter ajean.link'); return; }
|
||||
|
||||
async function onMsg(ev){
|
||||
// Anti-usurpation : n'accepte QUE des messages du portail ajean.link.
|
||||
if(ev.origin !== LOKI_APP_ORIGIN) return;
|
||||
const d = ev.data || {};
|
||||
if(d.type !== 'loki-link' || !d.token) return;
|
||||
window.removeEventListener('message', onMsg);
|
||||
try{
|
||||
const r = await jpost('/api/link/connect', { token: d.token });
|
||||
if(r && r.linked){
|
||||
toast('✓ accès distant connecté');
|
||||
if(r.serviceErr){ toast('token enregistré (service : '+r.serviceErr+')'); }
|
||||
loadRemote();
|
||||
} else {
|
||||
toast((r && r.error) || 'échec de la connexion');
|
||||
}
|
||||
}catch(e){ toast('erreur : '+e); }
|
||||
}
|
||||
window.addEventListener('message', onMsg);
|
||||
}
|
||||
|
||||
// Relance le tunnel avec la clé déjà enregistrée (sans repasser par la popup).
|
||||
async function remoteStart(){
|
||||
const b = document.getElementById('remote-start');
|
||||
if(b){ b.disabled = true; b.textContent = 'démarrage…'; }
|
||||
try{
|
||||
const r = await jpost('/api/link/start', {});
|
||||
if(r && r.active){ toast('✓ tunnel démarré'); }
|
||||
else { toast((r && r.error) || 'le tunnel n\'a pas démarré'); }
|
||||
}catch(e){ toast('erreur : '+e); }
|
||||
if(b){ b.disabled = false; b.textContent = 'démarrer le tunnel'; }
|
||||
loadRemote();
|
||||
}
|
||||
|
||||
async function remoteDisconnect(){
|
||||
const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'});
|
||||
if(!ok) return;
|
||||
try{ await jpost('/api/link/disconnect', {}); toast('accès distant coupé'); loadRemote(); }
|
||||
catch(e){ toast('erreur : '+e); }
|
||||
}
|
||||
|
||||
async function remotePairCode(){
|
||||
const box = document.getElementById('remote-pair');
|
||||
box.style.display=''; box.textContent='génération du code…';
|
||||
try{
|
||||
const r = await jpost('/api/link/paircode', {});
|
||||
if(r && r.code){
|
||||
box.innerHTML = 'Empreinte : <b>'+(r.fingerprint||'—')+'</b><br>Code d\'appairage (valable 10 min, usage unique) : <b>'+r.code+'</b>';
|
||||
} else {
|
||||
box.textContent = (r && r.error) || 'code indisponible';
|
||||
}
|
||||
}catch(e){ box.textContent='erreur : '+e; }
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// ===== 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();
|
||||
@@ -0,0 +1,187 @@
|
||||
// ─── Postes distants ─────────────────────────────────────────────────────────
|
||||
// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que
|
||||
// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit
|
||||
// les capacités autorisées et le dossier racine, puis peut lister/révoquer les
|
||||
// postes. Sous-réglage du mode agent (grisé quand l'agent est off).
|
||||
let nodeList = [];
|
||||
let nodeTarget = '';
|
||||
let nodeAllCaps = ['shell','read','write','list'];
|
||||
const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister'};
|
||||
|
||||
async function loadNode(){ renderNode(await jget('/api/node')); }
|
||||
|
||||
// Ouvre la modale de gestion (rafraîchit d'abord pour un état à jour). Appelée
|
||||
// par le bouton PC/wifi du composeur.
|
||||
function openNodeHub(){ loadNode(); showModal('node-hub-modal'); }
|
||||
|
||||
// Le bouton du composeur reflète SUR QUOI l'IA agit : écran = ce serveur, ondes
|
||||
// wifi (teinté accent) = un poste distant est la cible active.
|
||||
function updateNodeBtn(){
|
||||
const btn = document.getElementById('node-btn');
|
||||
if(!btn) return;
|
||||
const remote = !!nodeTarget;
|
||||
btn.classList.toggle('remote', remote);
|
||||
const n = remote ? nodeList.find(x=>x.slug===nodeTarget) : null;
|
||||
btn.title = remote ? ('Postes distants — l\'IA agit sur « '+(n?n.name:nodeTarget)+' »') : 'Postes distants — l\'IA agit sur ce serveur';
|
||||
}
|
||||
|
||||
function renderNode(r){
|
||||
nodeList = (r && r.nodes) || [];
|
||||
nodeTarget = (r && r.target) || '';
|
||||
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
|
||||
updateNodeBtn();
|
||||
const list = document.getElementById('node-hub-list');
|
||||
if(!list) return;
|
||||
list.textContent = '';
|
||||
if(!nodeList.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste appairé — appaire un PC pour que l\'IA puisse agir dessus)</div>';
|
||||
return;
|
||||
}
|
||||
const connected = nodeList.filter(n=>n.connected).length;
|
||||
// Sélecteur de CIBLE : sur quelle machine l'IA agit (ses outils bash/write/edit).
|
||||
renderNodeTarget(list, connected);
|
||||
nodeList.forEach(n=>{
|
||||
const row = document.createElement('div'); row.className = 'mcp-row'+(n.connected?'':' off');
|
||||
const dot = document.createElement('span'); dot.className = 'mcp-dot '+(n.connected?'mcp-dot-ok':'mcp-dot-off');
|
||||
dot.title = n.connected ? 'connecté' : 'hors ligne';
|
||||
|
||||
const info = document.createElement('div'); info.className = 'mcp-info';
|
||||
const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||'';
|
||||
const meta = document.createElement('div'); meta.className = 'mcp-meta';
|
||||
const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os);
|
||||
const caps = document.createElement('span'); caps.className = 'mcp-tools';
|
||||
caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité';
|
||||
meta.appendChild(caps);
|
||||
if(n.root){ const rt = document.createElement('span'); rt.className='mcp-tools'; rt.textContent = '📁 '+n.root; rt.title=n.root; meta.appendChild(rt); }
|
||||
info.appendChild(nm); info.appendChild(meta);
|
||||
|
||||
const edit = document.createElement('button'); edit.className='mcp-edit'; edit.title='régler'; edit.textContent='✎';
|
||||
edit.onclick = (e)=>{ e.stopPropagation(); openNodeEdit(n.id); };
|
||||
row.appendChild(dot); row.appendChild(info); row.appendChild(edit);
|
||||
row.onclick = ()=>openNodeEdit(n.id);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Sélecteur « L'IA agit sur : Ce serveur / <poste connecté> ». Les mêmes outils
|
||||
// (bash/write/edit) s'exécutent sur la machine choisie ; un poste hors ligne ne
|
||||
// peut pas être cible (fail-closed côté serveur).
|
||||
function renderNodeTarget(list, connected){
|
||||
const wrap = document.createElement('div'); wrap.className = 'node-target';
|
||||
const lab = document.createElement('div'); lab.className = 'node-target-l';
|
||||
lab.textContent = "L'IA agit sur :"; wrap.appendChild(lab);
|
||||
const opts = [{slug:'', name:'Ce serveur'}].concat(nodeList.filter(n=>n.connected).map(n=>({slug:n.slug, name:n.name})));
|
||||
// Cible sélectionnée mais hors ligne : on l'ajoute quand même, marquée, pour ne
|
||||
// pas la perdre silencieusement.
|
||||
if(nodeTarget && !opts.some(o=>o.slug===nodeTarget)){
|
||||
const n = nodeList.find(x=>x.slug===nodeTarget);
|
||||
opts.push({slug:nodeTarget, name:(n?n.name:nodeTarget)+' (hors ligne)', off:true});
|
||||
}
|
||||
opts.forEach(o=>{
|
||||
const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':'');
|
||||
const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget;
|
||||
rb.onchange = ()=>setNodeTarget(o.slug);
|
||||
const sp = document.createElement('span'); sp.textContent = o.name;
|
||||
l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l);
|
||||
});
|
||||
list.appendChild(wrap);
|
||||
}
|
||||
|
||||
async function setNodeTarget(slug){
|
||||
const r = await jpost('/api/node/target', {slug});
|
||||
nodeTarget = (r && r.target) || '';
|
||||
toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur');
|
||||
loadNode();
|
||||
}
|
||||
|
||||
// ── Appairage : génère un code que le poste échange contre sa clé ────────────
|
||||
function nodeCapCheckboxes(containerId, selected){
|
||||
const box = document.getElementById(containerId);
|
||||
box.textContent = '';
|
||||
const sel = new Set(selected||[]);
|
||||
nodeAllCaps.forEach(c=>{
|
||||
const lab = document.createElement('label'); lab.className='node-cap';
|
||||
const cb = document.createElement('input'); cb.type='checkbox'; cb.value=c; cb.checked = sel.has(c);
|
||||
const sp = document.createElement('span'); sp.textContent = nodeCapLabels[c]||c;
|
||||
lab.appendChild(cb); lab.appendChild(sp);
|
||||
box.appendChild(lab);
|
||||
});
|
||||
}
|
||||
function nodeCapValues(containerId){
|
||||
return [...document.querySelectorAll('#'+containerId+' input:checked')].map(cb=>cb.value);
|
||||
}
|
||||
|
||||
function openNodePair(){
|
||||
nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent
|
||||
document.getElementById('node-pair-root').value = '';
|
||||
document.getElementById('node-pair-out').style.display = 'none';
|
||||
showModal('node-pair-modal');
|
||||
}
|
||||
|
||||
async function genNodeCode(){
|
||||
const caps = nodeCapValues('node-pair-caps');
|
||||
if(!caps.length){ toast('choisis au moins une capacité'); return; }
|
||||
const root = document.getElementById('node-pair-root').value.trim();
|
||||
const r = await jpost('/api/node/pair', {caps, root});
|
||||
if(!r.ok){ toast(r.error||'échec'); return; }
|
||||
document.getElementById('node-pair-code').textContent = r.code;
|
||||
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
|
||||
const allow = caps.join(',');
|
||||
const rootArg = root ? (' --root "'+root+'"') : '';
|
||||
// Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
|
||||
// bout, le relais reste aveugle. --key = clé publique de l'agent (le poste
|
||||
// scelle vers elle), --machine = quelle machine joindre.
|
||||
const remote = 'loki remote install https://ajean.link --machine '+r.machine+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais).
|
||||
const lan = 'loki remote install '+location.origin+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
document.getElementById('node-pair-cmd').textContent = remote;
|
||||
const lanEl = document.getElementById('node-pair-cmd-lan');
|
||||
if(lanEl) lanEl.textContent = lan;
|
||||
document.getElementById('node-pair-out').style.display = '';
|
||||
loadNode();
|
||||
}
|
||||
|
||||
// ── Édition d'un poste appairé : capacités, dossier, révocation ──────────────
|
||||
let nodeEditing = null;
|
||||
function openNodeEdit(id){
|
||||
const n = nodeList.find(x=>x.id===id); if(!n) return;
|
||||
nodeEditing = id;
|
||||
nodeCapCheckboxes('node-pair-caps', n.caps);
|
||||
document.getElementById('node-pair-root').value = n.root||'';
|
||||
document.getElementById('node-pair-out').style.display = 'none';
|
||||
// Réutilise la modale d'appairage en mode édition : on remplace le pied.
|
||||
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Poste « '+n.name+' »'+(n.connected?' (connecté)':'');
|
||||
const footR = document.querySelector('#node-pair-modal .foot-r');
|
||||
footR.innerHTML = '';
|
||||
const cancel = document.createElement('button'); cancel.textContent='fermer'; cancel.onclick=()=>{ hideModal('node-pair-modal'); resetNodeFoot(); };
|
||||
const save = document.createElement('button'); save.className='btn-save'; save.textContent='enregistrer'; save.onclick=saveNodeCaps;
|
||||
footR.appendChild(cancel); footR.appendChild(save);
|
||||
const footL = document.querySelector('#node-pair-modal .foot-l');
|
||||
footL.innerHTML = '';
|
||||
const del = document.createElement('button'); del.className='btn-danger'; del.textContent='révoquer'; del.onclick=()=>revokeNode(id);
|
||||
footL.appendChild(del);
|
||||
showModal('node-pair-modal');
|
||||
}
|
||||
function resetNodeFoot(){
|
||||
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Appairer un poste distant';
|
||||
document.querySelector('#node-pair-modal .foot-l').innerHTML = '';
|
||||
const footR = document.querySelector('#node-pair-modal .foot-r');
|
||||
footR.innerHTML = '<button onclick="hideModal(\'node-pair-modal\')">fermer</button><button class="btn-save" onclick="genNodeCode()">générer le code</button>';
|
||||
nodeEditing = null;
|
||||
}
|
||||
async function saveNodeCaps(){
|
||||
if(!nodeEditing) return;
|
||||
const caps = nodeCapValues('node-pair-caps');
|
||||
const root = document.getElementById('node-pair-root').value.trim();
|
||||
const r = await jpost('/api/node/caps', {id:nodeEditing, caps, root});
|
||||
if(!r.ok){ toast(r.error||'échec'); return; }
|
||||
toast('poste mis à jour'); hideModal('node-pair-modal'); resetNodeFoot(); loadNode();
|
||||
}
|
||||
async function revokeNode(id){
|
||||
const n = nodeList.find(x=>x.id===id);
|
||||
if(!await askConfirm('Révoquer le poste « '+(n?n.name:'')+' » ? Sa clé sera oubliée et il sera déconnecté.', {title:'Révoquer le poste ?', okText:'Révoquer', danger:true})) return;
|
||||
await jpost('/api/node/revoke', {id});
|
||||
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user