function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; } function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; } async function runBenchUI(){ const btn = document.getElementById('btn-bench'); const rerun = document.getElementById('bench-rerun'); const body = document.getElementById('bench-body'); openBenchModal(); btn.disabled = true; btn.textContent = '⏳ bench…'; rerun.disabled = true; body.innerHTML = '
' + '
⏳
' + '
prompt 2000 tok + 300 decode
~10 secondes…
' + '
'; try{ const r = await jget('/api/bench'); if(!r.ok){ body.innerHTML = '
erreur: '+r.error+'
'; return; } const x = r.result; body.innerHTML = '
' + '
' + '
Prefill
' + '
'+x.prompt_per_second.toFixed(0)+'
' + '
tok/s
' + '
'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s
' + '
' + '
' + '
Decode
' + '
'+x.predicted_per_second.toFixed(1)+'
' + '
tok/s
' + '
'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s
' + '
' + '
' + '
total '+x.elapsed_sec.toFixed(2)+'s
'; } finally { btn.disabled = false; btn.textContent = '⚡ bench'; rerun.disabled = false; loadPresets(); } } async function switchTo(n,name){ if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return; toast('switching…'); const r=await jpost('/api/switch',{n:n}); toast(r.ok?'switched':'erreur'); setTimeout(loadAll,2000); } // editingKey = the identifier of the item being edited: a preset id (filename) // or a skill name. Empty string = creating a new item. let editingKey = '', editingKind = 'preset'; const KINDS = { // presets are keyed by `id` (filename) so several can share a display name; // skills keep name-as-identity (param 'name'). preset: {label:'Preset', param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, mem: {label:'Page', param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()}, }; async function openItem(kind, key){ const K = KINDS[kind]; const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); const d = await r.json(); editingKind = kind; editingKey = key || ''; const display = d.name || key || ''; document.getElementById('modal-title').textContent = key ? (K.label + ' · ' + display) : ('Nouveau ' + K.label.toLowerCase()); document.getElementById('m-name').value = display; document.getElementById('m-content').value = d.content || ''; document.getElementById('m-del').style.display = key ? 'inline-block' : 'none'; // Model picker is preset-only: it edits the MODEL= line of config.env. const modelRow = document.getElementById('m-model-row'); const settingsRow = document.getElementById('m-settings-row'); const delModelWrap = document.getElementById('m-del-model-wrap'); const rawHead = document.getElementById('m-raw-head'); const rawToggle = document.getElementById('m-raw-toggle'); const rawBody = document.getElementById('m-raw-body'); const rawCaret = document.getElementById('m-raw-caret'); if(kind === 'preset'){ modelRow.style.display = 'flex'; settingsRow.style.display = 'flex'; document.getElementById('m-hf-url').value = ''; document.getElementById('m-hf-progress').style.display = 'none'; document.getElementById('m-del-model').checked = false; document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); delModelWrap.style.display = key ? 'inline-flex' : 'none'; // Preset : la config brute est une ligne repliable, fermée par défaut. rawHead.textContent = 'Configuration'; rawToggle.style.display = ''; rawBody.style.display = 'none'; rawCaret.classList.remove('open'); await Promise.all([populateBackend(), populateModelPicker()]); } else { modelRow.style.display = 'none'; settingsRow.style.display = 'none'; delModelWrap.style.display = 'none'; // Page mémoire : le contenu EST le champ principal — affiché en clair. rawHead.textContent = 'Contenu'; rawToggle.style.display = 'none'; rawBody.style.display = ''; } document.getElementById('modal').style.display = 'flex'; } // Pretty-print bytes — handy for the dropdown options. function fmtSize(b){ if(b > 1e9) return (b/1e9).toFixed(1)+' GB'; if(b > 1e6) return (b/1e6).toFixed(0)+' MB'; if(b > 1e3) return (b/1e3).toFixed(0)+' KB'; return b+' B'; } // Read the current MODEL= value out of the textarea, handling quotes / spaces. function currentModelInTextarea(){ const txt = document.getElementById('m-content').value; const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m); return m ? m[1].trim() : ''; } async function populateModelPicker(){ const sel = document.getElementById('m-model'); const list = await jget('/api/models'); const cur = currentModelInTextarea(); // Strip directory; config.env conventionally only stores the file basename. const curBase = (cur.split('/').pop() || '').trim(); let html = ''; let matched = false; for(const m of list){ const sel = (m.name === curBase) ? ' selected' : ''; if(sel) matched = true; html += ''; } // If MODEL points to something not in JEAN_HOME, show it as a disabled hint. if(cur && !matched){ html += ''; } sel.innerHTML = html; } function onPickModel(){ const val = document.getElementById('m-model').value; if(!val) return; const ta = document.getElementById('m-content'); if(/^\s*MODEL\s*=.*$/m.test(ta.value)){ ta.value = ta.value.replace(/^\s*MODEL\s*=.*$/m, 'MODEL="'+val+'"'); } else { // No MODEL= line yet — prepend so it's visible at the top. ta.value = 'MODEL="'+val+'"\n' + ta.value; } toast('MODEL='+val); } // Choix du moteur PAR MODÈLE : 3 options (rapide / optimisé / personnalisé) // qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel // backend fait tourner ce modèle — la barre latérale ne fait qu'installer. function currentBinInTextarea(){ const txt = document.getElementById('m-content').value; const m = txt.match(/^\s*BIN\s*=\s*"?([^"\n]*)"?\s*$/m); return m ? m[1].trim() : ''; } // Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows). function sameBinPath(a, b){ const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase(); return !!a && !!b && n(a) === n(b); } // Écrit (ou remplace) la ligne BIN= dans le contenu du preset. function setBinInTextarea(val){ const ta = document.getElementById('m-content'); if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"'); else ta.value = 'BIN="'+val+'"\n' + ta.value; } let beFastPath = '', beOptPath = ''; async function populateBackend(){ // Chemins des deux moteurs gérés + liste des backends détectés (dossier jean). let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){} beFastPath = (lc.prebuilt && lc.prebuilt.bin) || ''; beOptPath = lc.bin || ''; // Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans // le dossier backends de jean (l'utilisateur peut y déposer son propre build). const detected = await jget('/api/backends'); const sel = document.getElementById('m-backend-detected'); let html = ''; for(const b of (detected||[])) html += ''; sel.innerHTML = html; document.getElementById('be-drop-hint').textContent = lc.backends_dir ? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : ''; // Sélectionne l'option correspondant au BIN actuel du preset. const cur = currentBinInTextarea(); let mode = 'custom'; if(sameBinPath(cur, beFastPath)) mode = 'fast'; else if(sameBinPath(cur, beOptPath)) mode = 'opt'; const radio = document.querySelector('input[name=m-be][value='+mode+']'); if(radio) radio.checked = true; toggleBackendCustom(mode); if(mode === 'custom') document.getElementById('m-backend-path').value = cur; } function toggleBackendCustom(mode){ document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none'; } function onBackendMode(mode){ toggleBackendCustom(mode); if(mode === 'fast'){ if(!beFastPath){ toast('installez d\'abord la version rapide (section MOTEUR)'); return; } setBinInTextarea(beFastPath); toast('moteur : rapide'); } else if(mode === 'opt'){ if(!beOptPath){ toast('installez d\'abord la version optimisée (section MOTEUR)'); return; } setBinInTextarea(beOptPath); toast('moteur : optimisé'); } // custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend } function onCustomPath(){ const v = document.getElementById('m-backend-path').value.trim(); if(v) setBinInTextarea(v); } function onPickDetected(){ const v = document.getElementById('m-backend-detected').value; if(!v) return; document.getElementById('m-backend-path').value = v; setBinInTextarea(v); toast('moteur personnalisé'); } // Read/write the QUANT= override line in the preset textarea. function currentQuantInTextarea(){ const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=\s*"?([^"\n]*)"?\s*$/mi); return m ? m[1].trim() : ''; } function applyQuant(){ const val = document.getElementById('m-quant').value.trim(); const ta = document.getElementById('m-content'); const re = /^\s*#?\s*QUANT\s*=.*$/mi; if(val){ if(re.test(ta.value)) ta.value = ta.value.replace(re, 'QUANT="'+val+'"'); else ta.value = ta.value.replace(/\s*$/,'') + '\nQUANT="'+val+'"\n'; } else { ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n'); } } // ---- Réglages simples : champs de formulaire <-> lignes du fichier .env ----- // Le fichier de config (textarea m-content) reste la source de vérité : chaque // champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT. function cfgReadKey(key){ const m = document.getElementById('m-content').value.match(new RegExp('^[ \\t]*'+key+'[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$','m')); return m ? m[1].trim() : ''; } function cfgWriteKey(key, val){ const ta = document.getElementById('m-content'); val = String(val==null?'':val).trim(); const reLine = new RegExp('^[ \\t]*'+key+'[ \\t]*=.*$','m'); if(val === ''){ // clé vidée → on retire la ligne ta.value = ta.value.replace(new RegExp('^[ \\t]*'+key+'[ \\t]*=.*\\n?','m'),'').replace(/\n{3,}/g,'\n\n'); return; } const line = key+'='+(/\s/.test(val) ? '"'+val+'"' : val); if(reLine.test(ta.value)) ta.value = ta.value.replace(reLine, line); else ta.value = ta.value.replace(/\s*$/,'') + '\n'+line+'\n'; } // EXTRA_ARGS : liste de drapeaux passés tels quels à llama-server. On les édite // jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans // toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute. function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); } function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); } function eaHasFlag(flag){ return eaTokens().includes(flag); } function eaToggleFlag(flag, on){ const t = eaTokens().filter(x=>x!==flag); if(on) t.push(flag); eaSetTokens(t); } function eaGetValued(flag){ const t = eaTokens(), i = t.indexOf(flag); return (i>=0 && i+1=0){ const hadVal = i+1{ const e=document.getElementById(id); if(e) e.value=v; }; set('s-ctx', cfgReadKey('CTX')); set('s-ngl', cfgReadKey('NGL')); set('s-threads', cfgReadKey('THREADS')); set('s-batch', cfgReadKey('BATCH')); set('s-ubatch', cfgReadKey('UBATCH')); set('s-tbatch', cfgReadKey('THREADS_BATCH')); set('s-kv', cfgReadKey('KV_TYPE')); set('s-moe', eaGetValued('--n-cpu-moe')); const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; }; chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING'))); chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn'))); chk('s-mlock', eaHasFlag('--mlock')); chk('s-nommap', eaHasFlag('--no-mmap')); } // Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset. function toggleRaw(){ const b = document.getElementById('m-raw-body'); const c = document.getElementById('m-raw-caret'); const show = b.style.display === 'none'; b.style.display = show ? '' : 'none'; if(c) c.classList.toggle('open', show); } const openPreset = (id)=>openItem('preset', id); const openMem = (n)=>openItem('mem', n); function closeModal(){ document.getElementById('modal').style.display = 'none'; } async function saveItem(){ const K = KINDS[editingKind]; const name = document.getElementById('m-name').value.trim(); const content = document.getElementById('m-content').value; if(!name){ toast('nom requis'); return; } // Presets: keyed by id (filename); duplicate display names are allowed. // Skills: keyed by name, rename via `old`. const payload = editingKind==='preset' ? {id: editingKey, name, content} : {name, old: editingKey, content}; const r = await jpost(K.saveUrl, payload); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } toast('enregistré'); closeModal(); K.reload(); } async function delItem(){ if(!editingKey) return; const K = KINDS[editingKind]; const name = document.getElementById('m-name').value.trim() || editingKey; const delModel = editingKind==='preset' && document.getElementById('m-del-model').checked; let msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?'; if(delModel) msg += '\n\n⚠️ Le fichier .gguf du modèle sera AUSSI supprimé du disque (irréversible).'; if(!await askConfirm(msg, {title:'Suppression', okText:'Supprimer', danger:true})) return; const payload = editingKind==='preset' ? {id: editingKey, deleteModel: delModel} : {name: editingKey}; const r = await jpost(K.delUrl, payload); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(delModel){ if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError); else if(r.modelDeleted) toast('preset + modèle supprimés'); else toast('supprimé (aucun modèle référencé)'); } else { toast('supprimé'); } closeModal(); K.reload(); } // Download a .gguf from Hugging Face, polling progress until done. let dlPoll = null; async function startDownload(){ const url = document.getElementById('m-hf-url').value.trim(); if(!url){ toast('colle un lien .gguf'); return; } const btn = document.getElementById('m-hf-btn'); const prog = document.getElementById('m-hf-progress'); btn.disabled = true; prog.style.display = 'block'; prog.textContent = 'démarrage…'; const r = await jpost('/api/models/download', {url}); if(!r.ok){ prog.innerHTML = 'erreur : '+(r.error||'')+''; btn.disabled=false; return; } const fname = r.filename; if(dlPoll) clearInterval(dlPoll); dlPoll = setInterval(async ()=>{ const list = await jget('/api/models/download/status'); const st = (list||[]).find(d=>d.filename===fname); if(!st){ return; } if(st.error){ prog.innerHTML = 'erreur : '+st.error+''; clearInterval(dlPoll); dlPoll=null; btn.disabled=false; return; } if(st.finished){ prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; clearInterval(dlPoll); dlPoll=null; btn.disabled=false; await populateModelPicker(); document.getElementById('m-model').value = fname; onPickModel(); return; } const pct = st.total>0 ? Math.round(st.done*100/st.total) : 0; const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : ''; prog.textContent = '⬇ '+fmtSize(st.done)+tot+' — '+fname; }, 800); } // Smart autoscroll: follow the bottom while the user hasn't manually scrolled // up. Re-stick when they scroll back near bottom themselves.