Files
Loki/internal/loki/ui/src/js/06-settings.js
T
MichaelandClaude Opus 5.5 199d7f1e99 Chat : compactage sans redite, file multi-appareils, bascule par identifiant
Repris d'AJEAN 0.17.4.

- Compactage : la dernière demande du torse n'est plus réinjectée quand la
  queue contient déjà un message utilisateur (le modèle répondait une
  seconde fois à une vieille question), ni quand le résumé a échoué (elle
  est déjà dans le torse dégraissé et réapparaissait après ses propres
  résultats d'outils).
- Le texte écrit avant un appel d'outil n'est plus rangé deux fois dans
  l'historique du modèle (message tool_calls ET réponse finale) : du
  contexte gaspillé à chaque tour d'outil.
- Deux appareils qui envoient en même temps : le second message part en
  file au lieu d'un 409. Une tâche planifiée garde le refus.
- Un raisonnement qui reprend après du texte de réponse ouvre une nouvelle
  bulle sous la réponse au lieu de remplir l'ancienne, repliée au-dessus.
- Bascule de preset par identifiant : le numéro seul visait un autre
  preset quand la liste avait bougé sur un autre appareil. Le numéro reste
  accepté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:21:18 +02:00

622 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// --- Réserve de hauteur des blocs peuplés par le réseau ---------------------
// Ces blocs sont vides (ou réduits à « … ») tant que le serveur n'a pas répondu,
// puis prennent leur vraie taille : tout le menu sautait alors d'un cran, ce qui
// se voyait surtout sur les sections du bas (« Moteur »). On mémorise leur
// hauteur d'une session à l'autre et on la réserve au démarrage ; la réserve
// tombe dès que le vrai contenu est là (elle ne fait que TENIR la place, elle
// n'affiche jamais rien de faux).
const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list'];
function reserveHeights(){
HRESERVE.forEach(id=>{
try{
const h=parseInt(localStorage.getItem('loki-h-'+id)||'0',10);
const el=document.getElementById(id);
if(el && h>0) el.style.minHeight=h+'px';
}catch(e){}
});
}
function releaseHeights(){
HRESERVE.forEach(id=>{
const el=document.getElementById(id); if(!el) return;
el.style.minHeight='';
try{ localStorage.setItem('loki-h-'+id, String(el.offsetHeight||0)); }catch(e){}
});
}
document.addEventListener('DOMContentLoaded', reserveHeights);
// Numéro (1-based) du preset vers lequel on bascule, tant que le serveur n'a pas
// fini de redémarrer le service. Basculer prend plusieurs secondes : sans ça, la
// liste restait FIGÉE sur l'ancien actif et on ne savait pas si le clic avait pris.
// Voir switchTo() dans 07-models.js.
let pendingPreset = 0;
// Dernière sélection PEINTE. La liste est redessinée à chaque rafraîchissement
// (et il y en a beaucoup : sondage de bascule, loadAll…) ; sans ce repère, les
// animations d'entrée (barre qui glisse, puce qui apparaît) repartaient à chaque
// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la
// sélection CHANGE vraiment.
let paintedSel = '';
async function loadPresets(){
const p=await jget('/api/presets');
// Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente.
if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0;
const act = p.find(x=>x.active);
// Build via DOM (not string concat) so preset names can contain anything —
// spaces, accents, quotes, < > & — without breaking markup or handlers.
const sp = document.getElementById('status-preset');
sp.textContent = act ? act.name : '';
sp.title = act ? 'preset actif' : '';
// Sélecteur de modèle de l'en-tête : même donnée, autre point d'entrée.
if(typeof renderModelSwitch === 'function') renderModelSwitch(p, act);
const cont=document.getElementById('presets');
cont.innerHTML='';
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
// Signature de la sélection : quel preset est actif, et vers lequel on bascule.
const sel = (act?act.id:'')+'|'+pendingPreset;
const moved = sel !== paintedSel; paintedSel = sel;
p.forEach((x,i)=>{
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name, x.id);
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';
// Preset externe : ni quant ni bench à montrer — le modèle tourne ailleurs.
// On affiche à la place d'où vient la réponse, sans quoi rien à l'écran ne
// distinguerait un preset local d'un appel qui part sur internet.
if(x.external){
const et=document.createElement('span'); et.className='xtag';
et.title='API externe — le chat part vers un serveur distant';
et.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="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/></svg>';
et.appendChild(document.createTextNode(x.model || 'externe'));
meta.appendChild(et);
}
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>';
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : 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();
loadMemEnc();
}
// 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','computer-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 acts=document.createElement('span'); acts.style.cssText='display:flex;gap:4px;flex:none';
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); };
acts.appendChild(btn);
// Déplacer vers un autre projet : proposé seulement s'il y en a un autre, et
// jamais sur l'index MEMORY.md, qui appartient au projet et ne se déplace pas.
if(typeof PROJECTS!=='undefined' && PROJECTS.length>1 && x.name.toUpperCase()!=='MEMORY.MD'){
const mv=document.createElement('button'); mv.textContent='déplacer'; mv.style.cssText='margin:0;padding:2px 8px;font-size:11px';
mv.onclick=e=>{ e.stopPropagation(); moveMemPageUI(x.name); };
acts.appendChild(mv);
}
row.appendChild(span); row.appendChild(acts); 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;
// --- Chiffrement de la mémoire (pages, discussions, blocs archivés, trackers) -
//
// La clé de chiffrement EST la clé de pilotage (localStorage 'loki.key'), qui ne
// vit que sur cet appareil — le serveur n'en garde qu'une empreinte, il ne peut
// donc pas ouvrir le coffre seul. Une seule clé pour l'accès ET le chiffrement :
// avoir l'interface sous les yeux suffit à déverrouiller, sans second secret à
// retenir.
let memUnlockDone=false; // tentative de déverrouillage déjà faite ce chargement
async function loadMemEnc(){
let h; try{ h=await jget('/api/mem/health'); }catch(e){ return; }
const tog=document.getElementById('mem-enc-toggle'); if(!tog) return;
// La case n'est cochée QUE si TOUT est réellement chiffré (h.fully). Un
// chiffrement partiel, raté ou verrouillé-incomplet la laisse décochée : si
// elle est cochée, c'est que ça l'est vraiment.
tog.checked=!!h.fully;
const st=document.getElementById('mem-enc-status');
if(st){
if(!h.encrypted) st.textContent='pages, discussions, blocs archivés et trackers sont en clair sous /data.';
else if(h.locked) st.textContent='chiffré — verrouillé : rien n\'est lisible tant que la clé n\'est pas fournie.';
else st.textContent='chiffré et déverrouillé · '+(h.pages|0)+' page(s)'+(h.mixed?' ⚠ certaines pages sont encore en clair':'');
}
// Résilience : si le drapeau a disparu mais qu'un coffre existe encore
// (vault_copies>0), on propose QUAND MÊME le déverrouillage — sinon la
// mémoire reste muette et rien ne demande la clé.
const flagLost = !h.encrypted && (h.vault_copies|0) > 0;
if(((h.encrypted && h.locked) || flagLost) && !memUnlockDone){
memUnlockDone=true;
const apiKey=localStorage.getItem('loki.key')||'';
if(apiKey){ const r=await jpost('/api/mem/unlock',{secret:apiKey}); if(r.ok){ loadMem(); return; } }
// Boucle : une clé fausse redemande tout de suite (pas besoin de recharger).
while(true){
const s2=await askPrompt('Saisis la clé de pilotage de cet appareil, ou la clé de récupération notée à l\'activation.',{title:'Mémoire verrouillée',placeholder:'clé de pilotage ou clé de récupération'});
if(!s2){ memUnlockDone=false; break; } // annulé : on redemandera au prochain chargement
const r=await jpost('/api/mem/unlock',{secret:s2});
if(r.ok){ loadMem(); return; }
await askAlert('Clé refusée.',{title:'Refusé'});
}
}
}
// --- Sauvegarde chiffrée en fichier (mémoire + presets + réglages) ----------
// Le corps est scellé avec la clé de la mémoire ; la restauration ne demande que
// la clé de pilotage (ou la clé de récupération). Aucun relais : c'est un
// fichier, que tu ranges où tu veux.
async function backupExport(){
let r;
try{ r = await jfetch('/api/backup/export'); }
catch(e){ await askAlert('échec : '+e); return; }
if(!r.ok){
let msg='échec'; try{ const j=await r.json(); msg=j.error||msg; }catch(_){}
await askAlert(msg, {title:'Sauvegarde impossible'});
return;
}
const blob = await r.blob();
// Le nom vient du serveur (Content-Disposition) ; repli daté côté client.
let name = 'loki-backup.lkbk';
const cd = r.headers.get('Content-Disposition')||'';
const m = /filename="([^"]+)"/.exec(cd);
if(m) name = m[1];
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(url), 10000);
toast('sauvegarde téléchargée');
}
async function backupImport(){
if(!await askConfirm("La mémoire, les presets et les réglages de CE serveur seront remplacés par ceux du paquet. Un snapshot de la mémoire actuelle est pris avant.",{title:'Restaurer une sauvegarde ?',okText:'Choisir le fichier',danger:true})) return;
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = '.lkbk,application/octet-stream';
inp.onchange = async () => {
const f = inp.files && inp.files[0];
if(!f) return;
const buf = new Uint8Array(await f.arrayBuffer());
// btoa par tranches : un spread sur un tableau de plusieurs Mo fait sauter
// la pile d'appels.
let bin = '';
for(let i=0;i<buf.length;i+=0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i+0x8000));
const data = btoa(bin);
// Boucle : une clé fausse redemande tout de suite.
while(true){
const secret = await askPrompt("Clé de pilotage de la machine d'origine, ou clé de récupération.",{title:'Restaurer',placeholder:'clé'});
if(!secret) return;
toast('restauration…');
let r; try{ r = await jpost('/api/backup/import',{data, secret}); }catch(e){ await askAlert('échec : '+e); return; }
if(r && r.ok){ await askAlert('Sauvegarde restaurée.',{title:'Terminé'}); loadMem(); return; }
await askAlert((r&&r.error)||'échec',{title:'Refusé'});
}
};
inp.click();
}
async function toggleMemEncrypt(){
const tog=document.getElementById('mem-enc-toggle');
if(tog.checked){
// Déjà chiffré mais incomplet (verrouillé, ou discussions à migrer) : on ne
// relance PAS un chiffrement, on complète en déverrouillant.
let cur; try{ cur=await jget('/api/mem/health'); }catch(e){}
if(cur && cur.encrypted){ memUnlockDone=false; await loadMemEnc(); return; }
const apiKey=localStorage.getItem('loki.key')||'';
if(!apiKey){ await askAlert("Le chiffrement utilise la clé de pilotage de cet appareil, et il n'y en a pas. Règle-la d'abord (loki set-web-key), puis reviens.",{title:'Clé de pilotage requise'}); tog.checked=false; return; }
const ok=await askConfirm("Les pages mémoire, les discussions, les blocs archivés et les trackers seront chiffrés sur le disque. La clé de cet appareil les ouvre ; une clé de récupération te sera donnée UNE fois — note-la, elle est ta seule porte de secours.",{title:'Chiffrer la mémoire ?',okText:'Chiffrer'});
if(!ok){ tog.checked=false; return; }
let r; try{ r=await jpost('/api/mem/encrypt',{password:apiKey}); }catch(e){ await askAlert('échec : '+e); tog.checked=false; return; }
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=false; loadMemEnc(); return; }
memUnlockDone=true;
await askAlert("Note cette clé de récupération, elle ne sera plus jamais affichée :\n\n"+r.recovery,{title:'Clé de récupération'});
toast('chiffrement activé'); loadMem();
} else {
const ok=await askConfirm('Tout sera réécrit EN CLAIR sur le disque.',{title:'Déchiffrer la mémoire ?',okText:'Déchiffrer',danger:true});
if(!ok){ tog.checked=true; return; }
let r; try{ r=await jpost('/api/mem/decrypt',{}); }catch(e){ await askAlert('échec : '+e); tog.checked=true; return; }
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=true; loadMemEnc(); return; }
toast('chiffrement désactivé'); loadMem();
}
}
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 (4 états), réglé PAR PROJET — chaque projet garde le sien.
const MEM_DESC={
always:'L\'index des pages est placé en tête de conversation : l\'IA voit d\'emblée ce qu\'elle sait, lit directement la bonne page et sauve d\'elle-même ce qui mérite d\'être retenu. Réglage propre au projet actif.',
search:'Rien n\'est injecté : l\'IA cherche dans sa mémoire avant chaque tâche. Contexte plus léger, démarrage plus rapide. Réglage propre au projet actif.',
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')); }
// Contrôle du navigateur (outils browser_*). Le serveur dit s'il a trouvé un
// binaire Chromium : sans lui, les outils ne sont pas annoncés au modèle, et
// l'interrupteur ne servirait qu'à promettre ce qui n'existe pas.
function renderComputer(s){
const tg=document.getElementById('computer-toggle');
if(tg) tg.checked = !!s.enabled;
// La pastille ne redit pas l'interrupteur : elle ne signale que l'anomalie
// (actif mais aucun navigateur trouvé).
if(s.enabled && !s.browser_ok) setBadge('computer-badge','warn','sans navigateur');
else setBadge('computer-badge', null);
const st=document.getElementById('computer-status');
if(!st) return;
if(!s.enabled){
st.textContent = "inactif : l'IA n'a aucun outil de navigateur" + (s.browser_ok ? '.' : " (aucun navigateur détecté dans le conteneur).");
st.style.color='';
return;
}
if(!s.browser_ok){ st.innerHTML = '⚠ aucun Chromium détecté — image bâtie avec PLAYWRIGHT=0 ? sinon définis LOKI_CHROME=&lt;chemin du binaire&gt;'; return; }
st.innerHTML = '<span style="color:var(--accent)">✓</span> navigateur prêt, outils de contrôle actifs'
+ (s.vision ? '' : ' — vision inactive : browser_screenshot indisponible');
}
async function loadComputer(){ renderComputer(await jget('/api/computer')); }
async function toggleComputer(){
const on=document.getElementById('computer-toggle').checked;
renderComputer(await jpost('/api/computer',{on}));
}
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
let OAI_KEY='', OAI_REVEAL=false;
async function copyText(txt, msg){
if(!txt){ toast('rien à copier'); return; }
try{ await navigator.clipboard.writeText(txt); }
catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
toast(msg||'copié');
}
function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); }
function renderApiKey(d){
OAI_KEY = d.key || '';
// L'adresse vient du SERVEUR (web_public_url.go) : adresse publique
// enregistrée, sinon l'origine par laquelle cette page a été ouverte. Elle
// était auparavant recomposée ici — 'http://'+host+':'+port+'/v1' — à partir
// du port du MOTEUR et d'une IP détectée côté Go, ce qui donnait en conteneur
// une adresse que personne ne pouvait joindre (bridge Docker, port non publié).
// location.origin ne sert plus que de filet si le serveur ne dit rien.
document.getElementById('oai-url').value = d.url || (location.origin + '/v1');
// Champ d'adresse publique : on ne l'écrase pas pendant la frappe.
const pa = document.getElementById('public-addr');
if(pa && document.activeElement !== pa) pa.value = d.public_url || '';
// Sans clé, l'endpoint est ouvert — et il l'est maintenant partout où
// l'interface l'est. Ça ne se dit pas à voix basse.
const warn = document.getElementById('oai-open-warn');
if(warn){
warn.style.display = d.set ? 'none' : '';
warn.textContent = d.set ? '' :
'⚠ Aucune clé : toute personne capable de joindre cette adresse peut utiliser votre modèle.';
}
const inp=document.getElementById('oai-key');
if(!d.set){ inp.value='(aucune clé — endpoint ouvert)'; inp.style.opacity=.6; }
else { inp.style.opacity=1; inp.value = OAI_REVEAL ? OAI_KEY : d.masked; }
document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none';
}
// Enregistre l'adresse publique. Le serveur normalise et refuse en clair : on
// relaie sa raison telle quelle, elle est écrite pour être lue.
async function savePublicAddr(){
const inp = document.getElementById('public-addr');
if(!inp) return;
const r = await jpost('/api/oai/public', {url: inp.value});
if(!r || !r.ok){ toast('adresse refusée : ' + ((r && r.error) || '')); return; }
toast(r.url ? 'adresse enregistrée' : 'adresse effacée — celle du navigateur sera utilisée');
loadApiKey();
}
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
// --- Export de la conversation ---------------------------------------------
// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de
// /api/chat/state ; 0 = conversation vide, et la fenêtre n'a alors rien à régler.
let xTurnsTotal = 0;
async function openExportModal(){
showModal('export-modal');
try{
const st = await jget('/api/chat/state');
xTurnsTotal = (st && st.turns) || 0;
}catch(e){ xTurnsTotal = 0; }
// Rien à exporter : on remplace les réglages par un message, plutôt que de
// proposer de tailler un fichier qui serait vide de toute façon.
const vide = xTurnsTotal === 0;
document.getElementById('x-empty').style.display = vide ? '' : 'none';
document.getElementById('x-body').style.display = vide ? 'none' : '';
document.getElementById('x-go').style.display = vide ? 'none' : '';
if(vide) return;
const el = document.getElementById('x-turns');
el.max = String(xTurnsTotal);
el.value = el.max; // par défaut : tout le fil
el.disabled = xTurnsTotal < 2; // un seul échange : rien à trancher
onExportFormat();
}
function closeExportModal(){ hideModal('export-modal'); }
// Le format ne change QUE le contenant : les options de contenu sont les mêmes
// des deux côtés. Il n'y a donc plus rien à montrer ou cacher ici, seulement le
// résumé à rafraîchir.
function onExportFormat(){ onExportPreview(); }
function exportFormat(){
const r = document.querySelector('input[name=x-fmt]:checked');
return r ? r.value : 'md';
}
// Construit la requête d'export à partir des cases. On n'envoie QUE ce qui
// s'écarte du défaut : l'URL reste lisible, et un export complet redevient le
// simple /api/chat/export.
function exportQuery(){
const p = new URLSearchParams();
if(exportFormat() === 'json') p.set('format','json');
const on = id => document.getElementById(id).checked;
if(!on('x-reasoning')) p.set('reasoning','0');
if(!on('x-tools')) p.set('tools','0');
if(!on('x-results')) p.set('results','0');
const t = exportTurns();
if(t > 0) p.set('turns', String(t));
const q = p.toString();
return '/api/chat/export' + (q ? '?'+q : '');
}
// Valeur de portée à envoyer : 0 = tout le fil. La butée DROITE du curseur vaut
// « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se
// périmerait à l'échange suivant.
function exportTurns(){
const v = parseInt(document.getElementById('x-turns').value, 10) || 0;
if(!xTurnsTotal || v >= xTurnsTotal) return 0;
return v;
}
function onExportPreview(){
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
const tools = document.getElementById('x-tools');
const results = document.getElementById('x-results');
results.disabled = !tools.checked;
if(!tools.checked) results.checked = false;
paintExportRange();
const off = [];
if(!document.getElementById('x-reasoning').checked) off.push('raisonnements');
if(!tools.checked) off.push('outils');
else if(!results.checked) off.push('sorties d\'outils');
document.getElementById('x-note').textContent = off.length
? 'Export sans ' + off.join(' ni ') + '.'
: '';
}
// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque
// pixel de glissement, il ne doit RIEN demander au serveur.
function paintExportRange(){
const el = document.getElementById('x-turns');
const n = parseInt(el.value, 10) || 0;
const max = Math.max(1, xTurnsTotal);
// La pastille ne parcourt pas toute la largeur : elle va de THUMB/2 à
// largeur - THUMB/2. On coupe le remplissage dans SON repère, sinon la piste
// colorée déborde à côté d'elle aux extrémités (même calcul que la barre GPU).
const frac = max > 1 ? (n - 1) / (max - 1) : 1;
const fill = document.getElementById('x-fill');
if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)';
const scope = document.getElementById('x-scope');
if(!scope) return;
scope.textContent = n >= xTurnsTotal
? 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'
: n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal;
}
async function runExport(){
const btn = document.getElementById('x-go');
btn.disabled = true;
try{ await downloadExport(exportQuery()); closeExportModal(); }
finally{ btn.disabled = false; }
}
// On passe par jfetch (et pas par un simple <a href>) pour deux raisons : la clé
// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas,
// et le chemin de base change derrière le tunnel (/u/<id>).
async function downloadExport(url){
toast('préparation de l\'export…');
try{
const r = await jfetch(url);
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
// Derrière app.ajean.link, le proxy chiffré réemballe TOUTE réponse en JSON :
// un export Markdown revenait donc comme une chaîne JSON entre guillemets,
// échappements compris. On la déballe. En local, rien à faire — le type de
// contenu n'est pas du JSON, et un export JSON est déjà à sa place.
let text = await r.text();
if((r.headers.get('Content-Type')||'').includes('json')){
try{ const v = JSON.parse(text); if(typeof v === 'string') text = v; }catch(_){}
}
const blob = new Blob([text]);
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom
// déduit du format DEMANDÉ — le type renvoyé ne dit plus rien derrière le
// tunnel, où tout arrive en application/json.
const cd = r.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : 'loki-conversation.' + (url.includes('format=json') ? 'json' : 'md');
// ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le
// redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte
// du const — l'export échouerait avant même de partir.
const blobURL = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobURL; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
// dans la foulée du clic.
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
toast('exporté : ' + name);
}catch(e){ toast('erreur : ' + e.message); }
}
// L'écoute réseau du moteur (HOST + pare-feu) n'a plus d'interrupteur : depuis
// que Loki sert /v1 sur son propre port, ouvrir le moteur sur le réseau n'a plus
// d'objet — et deux interrupteurs pour « rendre l'IA joignable » était
// exactement la confusion qu'on voulait lever. La route /api/network et la
// commande `loki network` restent, pour qui veut exposer le moteur en direct.
function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; }
async function apiKeyAction(action){
if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return;
if(action==='generate' && OAI_KEY && !await askConfirm('Générer une nouvelle clé invalide l\'ancienne (les clients devront être mis à jour). Le service va redémarrer.', {title:'Régénérer la clé ?', okText:'Générer'})) return;
OAI_REVEAL=(action==='generate'); // révèle la clé fraîche pour qu'on puisse la copier
toast('application…');
renderApiKey(await jpost('/api/apikey', {action}));
}
async function apiKeySet(){
const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'});
if(!k || !k.trim()) return;
OAI_REVEAL=true; toast('application…');
renderApiKey(await jpost('/api/apikey', {action:'set', key:k.trim()}));
}
async function toggleInternet(){
const on=document.getElementById('internet-toggle').checked;
const url=document.getElementById('crawl-url').value.trim();
// Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a
// besoin d'aucun réglage : on active directement.
if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
renderInternet(await jpost('/api/internet',{enabled:on, url}));
}
async function saveWebEngine(){
const engine=document.getElementById('web-engine').value;
renderInternet(await jpost('/api/internet',{engine}));
toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné');
}
async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
toast(url ? 'serveur Crawl4AI enregistré' : 'serveur Crawl4AI retiré');
}
async function saveCrawlKey(){
const el=document.getElementById('crawl-key'), key=el.value.trim();
if(!key){ toast('saisis une clé (ou « retirer » pour l\'enlever)'); return; }
renderInternet(await jpost('/api/internet',{key}));
toast('clé enregistrée');
}
async function clearCrawlKey(){
if(!await askConfirm('Retirer la clé du serveur Crawl4AI ?')) return;
document.getElementById('crawl-key').value='';
renderInternet(await jpost('/api/internet',{key:''}));
toast('clé retirée');
}
async function loadAll(){
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
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); }