// --- 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='(aucun)'; 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=''; 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=''; 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=''; // 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='
(aucune page mémoire)
'; return; } const matches = q ? memPages.filter(x=>(x.name+' '+(x.desc||'')).toLowerCase().includes(q)) : memPages; if(!matches.length){ list.innerHTML='
(aucun résultat pour « '+q.replace(/[<>&]/g,'')+' »)
'; 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 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 ) 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/). 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); }