mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-13 18:28:03 +02:00
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>
622 lines
37 KiB
JavaScript
622 lines
37 KiB
JavaScript
// --- 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=<chemin du binaire>'; 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); }
|