mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Postes distants : retirer le bouton oublié du composeur
Le retrait de l'accès distant s'était arrêté à mi-chemin. La section « Accès distant » de la barre latérale et son module 13-remote.js ont bien disparu au commit3740b88, mais l'autre moitié du même sujet est restée : le bouton « Postes distants » du composeur, l'icône écran/wifi sous « send ». Ce sont deux fonctionnalités distinctes dans le code (relay_* pour le tunnel ajean.link, node_* pour les postes appairés), mais à l'usage c'est la même chose : faire agir Loki ailleurs que sur le serveur qui l'héberge. Sur une installation en conteneur, ni l'une ni l'autre n'a d'objet, et ce bouton occupait une place dans une ligne d'outils déjà dense pour ouvrir une modale d'appairage qui ne servira jamais. Partent avec lui : les deux modales (celle d'appairage servait aussi d'écran d'édition d'un poste, via un remplacement de pied), le module 15-node.js entier, et les règles CSS #node-btn et .node-*. Le code serveur ne bouge pas — mêmes raisons qu'en3740b88: les routes /api/node/* et le serveur WebSocket d'appairage restent en place mais deviennent inatteignables, plus rien ne pouvant générer de code d'appairage. Les retirer créerait un conflit à chaque reprise de l'amont pour un gain nul. Un piège mérite d'être signalé, et il est maintenant commenté dans le code : loadAll() enchaîne ses chargements dans un Promise.allSettled, qui AVALE une ReferenceError. Oublier de retirer loadNode() de cette ligne n'aurait rien cassé visiblement — la page se serait simplement chargée à moitié. Le test navigateur écoute donc pageerror et échoue au moindre message. Vérifié en 1400×900, thèmes clair et sombre : aucune erreur JS au chargement, bouton et modales absents du DOM, openNodeHub indéfini, et le pied de carte garde son décompte de contexte, « compacter » et le trombone, tous atteignables au clic. Le composeur perd 26 px de large ; comme sa hauteur est mesurée depuis le correctif précédent, la réserve sous le fil suit toute seule — overlap.js repasse au vert de 380 à 2000 px. Deux échecs relevés au premier passage venaient du banc d'essai, pas du code : /api/backends/devices répond 400 parce que le BIN du fixture n'est pas un vrai llama-server, et « compacter » est masqué tant que le contexte est sous 50 %. Les assertions ont été corrigées, pas le code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
This commit is contained in:
6 files changed
+18
-560
No files matched your search
@@ -186,6 +186,10 @@ Retirées par ce fork :
|
||||
propre réseau n'en a pas l'usage. Le code serveur du relais reste en place
|
||||
mais **inerte** (aucun jeton, aucune section pour en fournir un) : le retirer
|
||||
créerait un conflit à chaque reprise de l'amont.
|
||||
- **Postes distants** (faire agir l'agent sur un autre PC appairé) : bouton du
|
||||
composeur, modales d'appairage et module JS supprimés. Même traitement que
|
||||
ci-dessus — les routes `/api/node/*` subsistent mais plus rien ne peut
|
||||
générer de code d'appairage, donc aucun poste ne peut se connecter.
|
||||
- **Catalogue de modèles distant** : il interrogeait `ajean.link/models.json`,
|
||||
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
|
||||
recherche Hugging Face le remplace.
|
||||
|
||||
+7
-280
@@ -1140,20 +1140,6 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||||
#attach:active{transform:scale(.9)}
|
||||
#attach svg{display:block}
|
||||
/* Bouton « Postes distants » du composeur : même gabarit que le trombone. L'icône
|
||||
dépend de la cible active — écran (local) par défaut, ondes wifi quand un poste
|
||||
distant est sélectionné, alors teinté en accent pour signaler « l'IA agit
|
||||
ailleurs ». Les deux SVG coexistent ; on n'en montre qu'un via la classe. */
|
||||
#node-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
|
||||
display:flex;align-items:center;justify-content:center;border-radius:999px;
|
||||
background:transparent;border:1px solid transparent;color:var(--dim)}
|
||||
#node-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||||
#node-btn:active{transform:scale(.9)}
|
||||
#node-btn svg{display:block}
|
||||
#node-btn .ic-remote{display:none}
|
||||
#node-btn.remote{color:var(--accent)}
|
||||
#node-btn.remote .ic-here{display:none}
|
||||
#node-btn.remote .ic-remote{display:block}
|
||||
/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte.
|
||||
Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de
|
||||
dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant
|
||||
@@ -1269,21 +1255,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
|
||||
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
|
||||
|
||||
/* Postes distants : cases de capacités, code d'appairage, commande. */
|
||||
.node-caps{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none}
|
||||
.node-cap input{margin:0}
|
||||
.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0}
|
||||
.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px}
|
||||
|
||||
/* Poste distant : sélecteur de cible d'exécution. */
|
||||
.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px}
|
||||
.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%}
|
||||
.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer}
|
||||
.node-target-opt.on{border-color:var(--accent);color:var(--accent)}
|
||||
.node-target-opt.off{opacity:.6}
|
||||
.node-target-opt input{margin:0}
|
||||
|
||||
/* ===== Direction « Terre » : surfaces en cartes =======================
|
||||
La charte pose des CARTES arrondies séparées par une gouttière, au lieu
|
||||
d'une colonne à filets. Bloc en fin de feuille pour prendre le pas sur les
|
||||
@@ -1756,13 +1727,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<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,
|
||||
@@ -1963,59 +1927,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
</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">
|
||||
@@ -3214,10 +3125,13 @@ async function clearCrawlKey(){
|
||||
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(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
|
||||
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
|
||||
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -5941,193 +5855,6 @@ async function attachPaths(){
|
||||
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
|
||||
}
|
||||
renderAttach();
|
||||
// ─── 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é');
|
||||
}
|
||||
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
|
||||
//
|
||||
// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils :
|
||||
|
||||
@@ -361,13 +361,6 @@
|
||||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||
<span id="ctx-text" class="muted">0 / 0</span>
|
||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||
<!-- 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,
|
||||
@@ -568,59 +561,6 @@
|
||||
</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">
|
||||
|
||||
@@ -488,10 +488,13 @@ async function clearCrawlKey(){
|
||||
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(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
|
||||
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
|
||||
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -1,187 +0,0 @@
|
||||
// ─── 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é');
|
||||
}
|
||||
@@ -1114,20 +1114,6 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||||
#attach:active{transform:scale(.9)}
|
||||
#attach svg{display:block}
|
||||
/* Bouton « Postes distants » du composeur : même gabarit que le trombone. L'icône
|
||||
dépend de la cible active — écran (local) par défaut, ondes wifi quand un poste
|
||||
distant est sélectionné, alors teinté en accent pour signaler « l'IA agit
|
||||
ailleurs ». Les deux SVG coexistent ; on n'en montre qu'un via la classe. */
|
||||
#node-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
|
||||
display:flex;align-items:center;justify-content:center;border-radius:999px;
|
||||
background:transparent;border:1px solid transparent;color:var(--dim)}
|
||||
#node-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||||
#node-btn:active{transform:scale(.9)}
|
||||
#node-btn svg{display:block}
|
||||
#node-btn .ic-remote{display:none}
|
||||
#node-btn.remote{color:var(--accent)}
|
||||
#node-btn.remote .ic-here{display:none}
|
||||
#node-btn.remote .ic-remote{display:block}
|
||||
/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte.
|
||||
Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de
|
||||
dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant
|
||||
@@ -1243,21 +1229,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
|
||||
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
|
||||
|
||||
/* Postes distants : cases de capacités, code d'appairage, commande. */
|
||||
.node-caps{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none}
|
||||
.node-cap input{margin:0}
|
||||
.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0}
|
||||
.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px}
|
||||
|
||||
/* Poste distant : sélecteur de cible d'exécution. */
|
||||
.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px}
|
||||
.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%}
|
||||
.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer}
|
||||
.node-target-opt.on{border-color:var(--accent);color:var(--accent)}
|
||||
.node-target-opt.off{opacity:.6}
|
||||
.node-target-opt input{margin:0}
|
||||
|
||||
/* ===== Direction « Terre » : surfaces en cartes =======================
|
||||
La charte pose des CARTES arrondies séparées par une gouttière, au lieu
|
||||
d'une colonne à filets. Bloc en fin de feuille pour prendre le pas sur les
|
||||
|
||||
Reference in new issue
Block a user