Files
Loki/ui/index.html
T
nathaninline a8a016c1b8 v0.2.2 : mise a jour de securite — boite noire E2E & relais aveugle
- Chiffrement E2E du chat navigateur<->agent (e2e.go) : le relais ne voit
  que de l'opaque, ancre par empreinte X25519 confirmee au `jean link`.
- `jean link` refuse desormais tout chat en clair via le tunnel (403) ;
  seul /api/e2e/chat porte du contenu. Endpoint OpenAI clair desactive par
  defaut (JEAN_LINK_ALLOW_OAI=1 pour le reactiver).
- Le portail web n'est plus servi par le relais : code livre par une origine
  hors de son controle, le relais devient un pur tube aveugle.
2026-06-23 19:45:17 +02:00

902 lines
52 KiB
HTML
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.
<!doctype html>
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>jean</title>
<style>
:root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#e6edf3;--dim:#7d8590;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--err:#f85149;--mag:#bc8cff}
*{box-sizing:border-box}
body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);color:var(--text);height:100vh;height:100dvh;display:flex;font-size:14px}
.side{width:320px;border-right:1px solid var(--border);padding:16px;overflow-y:auto;flex-shrink:0;scrollbar-width:none;-ms-overflow-style:none}
.side::-webkit-scrollbar{display:none}
.main{flex:1;display:flex;flex-direction:column;min-width:0}
#menubtn{display:none;position:fixed;top:10px;left:10px;z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer}
#backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10}
@media (max-width:720px){
.side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg)}
.side.open{transform:translateX(0)}
#menubtn,#backdrop.open{display:block}
body.drawer-open #menubtn{display:none}
.main{width:100%}
#chat{padding:60px 8px 12px}
.msg{max-width:92%}
#chat .msg.assistant,#chat .msg.reasoning{max-width:100%}
#inputbar{padding:8px;gap:4px}
#send,#stop{padding:0 12px}
h1{margin-left:48px}
}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){
.side{width:380px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:9px}
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px}
}
h1{margin:0 0 4px;font-size:18px;color:var(--accent)}
h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600}
details{margin:8px 0;border-top:1px solid var(--border);padding-top:6px}
details>summary{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600;cursor:pointer;padding:4px 0;list-style:none;display:flex;justify-content:space-between;align-items:center;user-select:none}
details>summary::-webkit-details-marker{display:none}
details>summary::after{content:"▸";transition:transform .15s;color:var(--dim);font-size:10px}
details[open]>summary::after{transform:rotate(90deg)}
details>summary:hover{color:var(--text)}
details>div,details>textarea{margin-top:6px}
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer}
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
details[open]>summary>.preset-add{display:inline-block}
.tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
.tag.ok{color:var(--ok);border-color:var(--ok)}
.tag.err{color:var(--err);border-color:var(--err)}
button{background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font:inherit;cursor:pointer;margin:2px}
button:hover{border-color:var(--accent);color:var(--accent)}
button:disabled{opacity:.5;cursor:not-allowed}
.row{display:flex;gap:4px;flex-wrap:wrap}
.preset{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;margin:4px 0;cursor:pointer;background:var(--panel)}
.preset:hover{border-color:var(--accent)}
.preset.active{border-color:var(--ok);color:var(--ok)}
.preset>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.preset>button{flex-shrink:0}
.preset-info{flex:1;min-width:0}
.preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word}
.preset-edit{flex-shrink:0;align-self:flex-start;margin:0;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.45}
.preset-edit:hover{opacity:1;color:var(--accent)}
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em;text-transform:uppercase}
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em}
.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em}
.kv{display:flex;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px}
.kv span:first-child{color:var(--dim);flex-shrink:0}
.kv span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:right;min-width:0}
.bar{height:6px;background:var(--panel);border-radius:3px;overflow:hidden;margin:2px 0}
.bar>div{height:100%;background:var(--accent)}
#chat{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:14px}
.msg{max-width:80%;padding:10px 14px;border-radius:10px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
.msg.assistant,.msg.reasoning{white-space:normal}
.msg .body{overflow-wrap:anywhere;word-break:break-word}
.msg.user{align-self:flex-end;background:#1f6feb;color:#fff}
.msg.assistant{align-self:flex-start;background:var(--panel);border:1px solid var(--border)}
.msg .body>*{margin:1em 0}
.msg .body>*:first-child{margin-top:0}
.msg .body>*:last-child{margin-bottom:0}
.msg .body h1,.msg .body h2,.msg .body h3,.msg .body h4{font-weight:600;line-height:1.25;margin:1.2em 0 .5em}
.msg .body h1{font-size:1.3em} .msg .body h2{font-size:1.15em} .msg .body h3{font-size:1em} .msg .body h4{font-size:.95em}
.msg .body ul,.msg .body ol{padding-left:1.4em}
.msg .body li{margin:.15em 0}
.msg .body li>p{margin:0}
.msg.assistant .body code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere;word-break:break-word}
.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:5px;opacity:.5;transition:opacity .15s,color .15s}
.msg.assistant .body pre:hover .copybtn,.msg.reasoning .body pre:hover .copybtn{opacity:1}
.copybtn:hover{border-color:var(--accent);color:var(--accent)}
.copybtn.done{color:var(--ok);border-color:var(--ok);opacity:1}
.msg.assistant .body pre code,.msg.reasoning .body pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4}
.msg.assistant .body blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)}
.msg.assistant .body a{color:var(--accent);text-decoration:none}
.msg.assistant .body a:hover{text-decoration:underline}
.msg.assistant .body table{border-collapse:collapse;margin:.5em 0}
.msg.assistant .body th,.msg.assistant .body td{border:1px solid var(--border);padding:4px 8px}
.msg.assistant .body hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
#scrollbtn{position:absolute;bottom:80px;right:20px;background:var(--panel);border:1px solid var(--border);color:var(--accent);width:36px;height:36px;border-radius:50%;cursor:pointer;display:none;font-size:16px;z-index:5}
#scrollbtn.show{display:block}
.msg.reasoning{align-self:flex-start;background:transparent;border:1px dashed var(--border);color:var(--mag);font-size:12px;max-width:90%;overflow-wrap:anywhere;word-break:break-word}
.msg.tool{align-self:flex-start;background:var(--panel);border:1px solid var(--border);max-width:100%;white-space:normal;padding:8px 12px}
.msg.tool .tool-head{font-size:12px;color:var(--accent);font-weight:600;margin-bottom:6px}
.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
.msg.tool pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
.msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
@keyframes toolblink{0%,100%{opacity:.45}50%{opacity:1}}
.msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block}
#inputbar{border-top:1px solid var(--border);padding:12px;display:flex;gap:8px}
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:44px;max-height:200px}
#input:focus{outline:none;border-color:var(--accent)}
#send{padding:0 20px}
.muted{color:var(--dim);font-size:12px}
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:6px;opacity:0;transition:opacity .2s;pointer-events:none}
#toast.show{opacity:1}
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
/* iOS Safari auto-zoome au focus sur tout champ < 16px. On force 16px sur les
appareils tactiles uniquement (le desktop garde son rendu, le pinch-zoom
manuel reste possible). */
@media (hover:none) and (pointer:coarse){
#input,#sysprompt,#modal input,#modal textarea,#modal select{font-size:16px}
}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<div id="backdrop" onclick="toggleSide()"></div>
<div class="side" id="side">
<h1>jean</h1>
<div id="statusline" class="muted"><span id="status-svc">…</span> <span id="status-preset"></span></div>
<details open><summary>GPU</summary>
<div id="vram" class="muted">…</div>
</details>
<details open><summary>Config active</summary>
<div id="cfg" class="muted">…</div>
</details>
<details><summary>Presets<button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter">+</button></summary>
<div id="presets"></div>
</details>
<details><summary>Accès machine <span id="tools-badge" style="margin-left:auto;font-size:10px"></span></summary>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0"><input type="checkbox" id="tools-toggle" onchange="toggleTools()"> activer l'accès machine</label>
<div class="muted" style="font-size:11px">⚠️ donne à l'IA un accès shell complet au serveur — elle peut inspecter, lancer des scripts et tout faire via le terminal (timeout 30s, max 300s)</div>
</details>
<details><summary>Skills <span id="skills-badge" style="margin-left:auto;font-size:10px"></span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openSkill('')" title="ajouter">+</button></summary>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0"><input type="checkbox" id="skills-toggle" onchange="toggleSkills()"> activer</label>
<div id="skills-list" class="muted" style="font-size:12px"></div>
</details>
<details><summary>System prompt</summary>
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:70px;resize:vertical"></textarea>
</details>
<details><summary>Service</summary>
<div class="row">
<button onclick="act('start')">start</button>
<button onclick="act('restart')">restart</button>
<button onclick="act('stop')">stop</button>
</div>
</details>
<details><summary>Actions</summary>
<div class="row">
<button onclick="loadAll()">↻ refresh</button>
<button onclick="resetChat()">⌫ clear chat</button>
<button id="btn-bench" onclick="runBenchUI()">⚡ bench</button>
</div>
</details>
</div>
<div class="main" style="position:relative">
<div id="chat"></div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
<div id="ctxbar" style="padding:4px 12px 0;font-size:11px;display:flex;align-items:center;gap:8px">
<div style="flex:1;height:6px;background:var(--panel);border:1px solid var(--border);border-radius:4px;overflow:hidden">
<div id="ctx-fill" style="height:100%;width:0%;background:var(--ok,#3a7);transition:width .3s,background .3s"></div>
</div>
<span id="ctx-text" class="muted" style="white-space:nowrap">contexte 0 / 0</span>
<button id="ctx-compact" onclick="compactContext()" style="display:none;margin:0;padding:2px 10px;font-size:11px;background:#3d2f1f;border-color:var(--warn,#c93)">compacter</button>
</div>
<div id="inputbar">
<textarea id="input" placeholder="message…" onkeydown="onKey(event)"></textarea>
<button id="send" onclick="send()">send</button>
<button id="stop" onclick="stopGen()" style="display:none;background:#3d1f23;border-color:var(--err);color:var(--err)">stop</button>
</div>
<div class="muted" style="padding:2px 12px 6px;font-size:10px;text-align:center;opacity:.7">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div>
<div id="toast"></div>
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong>⚡ Benchmark</strong>
<button onclick="closeBenchModal()" style="margin:0">×</button>
</div>
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
<button onclick="closeBenchModal()">fermer</button>
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
</div>
</div>
</div>
<div id="modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:30;align-items:center;justify-content:center;padding:20px">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:700px;max-height:90vh;display:flex;flex-direction:column">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong id="modal-title">Preset</strong>
<button onclick="closeModal()" style="margin:0">×</button>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:10px;overflow:auto;flex:1">
<label class="muted">Nom <span style="opacity:.6">(espaces et accents OK, sauf / et \)</span></label>
<input id="m-name" style="background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit">
<div id="m-model-row" style="display:none">
<label class="muted">Backend <span style="opacity:.6">(remplace la ligne BIN=)</span></label>
<select id="m-backend" onchange="onPickBackend()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin:6px 0 10px"></select>
<label class="muted">Modèle <span style="opacity:.6">(remplace la ligne MODEL=)</span></label>
<select id="m-model" onchange="onPickModel()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin-top:6px"></select>
<label class="muted" style="margin-top:10px;display:block">Télécharger depuis Hugging Face <span style="opacity:.6">(lien vers un .gguf)</span></label>
<div style="display:flex;gap:6px;margin-top:6px">
<input id="m-hf-url" placeholder="https://huggingface.co/…/resolve/main/model.gguf" style="flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit">
<button id="m-hf-btn" onclick="startDownload()" style="flex-shrink:0">⬇ télécharger</button>
</div>
<div id="m-hf-progress" class="muted" style="display:none;font-size:11px;margin-top:6px"></div>
<label class="muted" style="margin-top:10px;display:block">Quantization (tag) <span style="opacity:.6">(vide = détection auto depuis le nom du modèle)</span></label>
<input id="m-quant" oninput="applyQuant()" placeholder="ex: Q4_K_M, IQ3_XXS, BF16…" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin-top:6px">
</div>
<label class="muted">Contenu</label>
<textarea id="m-content" spellcheck="false" style="background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:280px;resize:vertical"></textarea>
</div>
<div style="padding:14px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:6px;flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<button id="m-del" onclick="delItem()" style="background:#3d1f23;border-color:var(--err);color:var(--err);margin:0">supprimer</button>
<label id="m-del-model-wrap" style="display:none;align-items:center;gap:5px;font-size:11px;color:var(--dim)"><input type="checkbox" id="m-del-model"> + le modèle .gguf</label>
</div>
<div style="display:flex;gap:6px">
<button onclick="closeModal()">annuler</button>
<button onclick="saveItem()" style="border-color:var(--accent);color:var(--accent)">enregistrer</button>
</div>
</div>
</div>
</div>
<script src="marked.min.js"></script>
<script>
// marked: GFM on, no auto-breaks (single newlines stay inline).
marked.setOptions({ gfm: true, breaks: false });
// Pre-process to fix common LLM markdown mistakes before handing to marked:
// - code fences glued to preceding text on the same line ("foo ```ruby ...")
// - fences without a closing newline
// - 3+ consecutive blank lines collapsed to one (LLMs love padding)
function fixMd(s){
if(!s) return '';
// Force a newline before a fence that's been glued to preceding text
// (common LLM mistake: "...crée le fichier et```ruby"). We do NOT split
// *after* the fence — the part after the fence is the language identifier.
s = s.replace(/([^\n])(\s*)(```+|~~~+)(?=\w*\s*\n)/g, '$1\n$3');
// Collapse runs of blank lines that LLMs love to emit.
s = s.replace(/\n{3,}/g, '\n\n');
return s;
}
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
let msgs = [];
let busy = false;
function toggleSide(){ document.getElementById('side').classList.toggle('open'); document.getElementById('backdrop').classList.toggle('open'); document.body.classList.toggle('drawer-open'); }
function saveSys(){ localStorage.setItem('jean.sys', document.getElementById('sysprompt').value); }
document.addEventListener('DOMContentLoaded', ()=>{ document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; restoreChat(); });
function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); }
// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*.
// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage.
let TOKEN = localStorage.getItem('jean.key') || '';
function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; }
// Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link.
// Garde les chemins absolus (/api/…) corrects derrière le tunnel.
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
async function jfetch(u, opts){
opts = opts || {};
opts.headers = authHeaders(opts.headers);
if(u.charAt(0) === '/') u = API_BASE + u;
let r = await fetch(u, opts);
if(r.status === 401){
const k = prompt('Clé de pilotage jean requise :', '');
if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
}
return r;
}
async function jget(u){ const r=await jfetch(u); return r.json(); }
async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); }
async function loadStatus(){
const s=await jget('/api/status');
const t=s.active?'<span class="tag ok">● active</span>':'<span class="tag err">○ '+s.state+'</span>';
document.getElementById('status-svc').innerHTML = t + ' <span class="muted">:'+s.port+'</span>';
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
}
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
let CTX_MAX=0, CTX_USED=0;
function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
function updateCtxMeter(){
if(!CTX_MAX) return;
const pct=Math.min(100, Math.round(CTX_USED*100/CTX_MAX));
const fill=document.getElementById('ctx-fill');
fill.style.width=pct+'%';
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
document.getElementById('ctx-text').textContent='contexte '+CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
document.getElementById('ctx-compact').style.display = pct>=90 ? 'inline-block' : 'none';
}
async function loadVram(){
const gpus=await jget('/api/vram');
document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{
const pct=Math.round(g.used*100/g.total);
return '<div style="margin:6px 0"><div style="font-size:12px">'+g.name+'</div>'+
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
'<div class="muted">'+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB · GPU '+g.util+'% · '+g.temp+'°C</div></div>';
}).join('') || '<span class="muted">(pas de GPU)</span>';
}
async function loadCfg(){
const c=await jget('/api/config');
const keys=['BIN','MODEL','CTX','BATCH','UBATCH','NGL','PORT'];
document.getElementById('cfg').innerHTML = keys.filter(k=>c[k]).map(k=>{
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
return '<div class="kv"><span>'+k+'</span><span title="'+v.replace(/"/g,'&quot;')+'">'+v+'</span></div>';
}).join('');
}
async function loadPresets(){
const p=await jget('/api/presets');
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.innerHTML='';
if(act){
const tag=document.createElement('span');
tag.className='tag'; tag.style.borderColor='var(--accent)'; tag.style.color='var(--accent)';
tag.textContent=act.name; sp.appendChild(tag);
}
const cont=document.getElementById('presets');
cont.innerHTML='';
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
p.forEach((x,i)=>{
const row=document.createElement('div');
row.className='preset'+(x.active?' active':'');
row.onclick=()=>switchTo(i+1, x.name);
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
nm.textContent=(x.active?'● ':'')+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';
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');
edit.className='preset-edit'; edit.title='éditer'; edit.textContent='✎';
edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); };
row.appendChild(info); row.appendChild(edit);
cont.appendChild(row);
});
}
async function loadSkills(){
const s=await jget('/api/skills');
document.getElementById('skills-toggle').checked = s.enabled;
document.getElementById('skills-badge').innerHTML = s.enabled
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
document.getElementById('skills-list').innerHTML = (s.skills||[]).length
? s.skills.map(x=>'<div class="preset" style="font-size:12px" onclick="openSkill(\''+x.name+'\')"><span><b style="color:var(--text)">'+x.name+'</b> <span class="muted">— '+x.desc+'</span></span><button onclick="event.stopPropagation();openSkill(\''+x.name+'\')" style="margin:0;padding:2px 8px;font-size:11px">edit</button></div>').join('')
: '<div class="muted">(aucun skill)</div>';
}
async function toggleSkills(){
const on=document.getElementById('skills-toggle').checked;
await jpost('/api/skills/toggle',{on}); loadSkills();
}
async function loadTools(){
const t=await jget('/api/tools');
document.getElementById('tools-toggle').checked = t.enabled;
document.getElementById('tools-badge').innerHTML = t.enabled
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
}
async function toggleTools(){
const on=document.getElementById('tools-toggle').checked;
if(on && !confirm("Activer l'accès machine ? L'IA aura un accès shell complet au serveur.")){ document.getElementById('tools-toggle').checked=false; return; }
await jpost('/api/tools/toggle',{on}); loadTools();
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadSkills(),loadTools()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; }
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 =
'<div style="text-align:center;padding:20px 0">' +
'<div style="font-size:24px;animation:spin 1s linear infinite;display:inline-block">⏳</div>' +
'<div class="muted" style="margin-top:8px">prompt 2000 tok + 300 decode<br>~10 secondes…</div>' +
'</div>';
try{
const r = await jget('/api/bench');
if(!r.ok){
body.innerHTML = '<div style="color:var(--err);text-align:center">erreur: '+r.error+'</div>';
return;
}
const x = r.result;
body.innerHTML =
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Prefill</div>' +
'<div style="font-size:26px;color:var(--accent);font-weight:600;margin:6px 0">'+x.prompt_per_second.toFixed(0)+'</div>' +
'<div class="muted">tok/s</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s</div>' +
'</div>' +
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Decode</div>' +
'<div style="font-size:26px;color:var(--ok);font-weight:600;margin:6px 0">'+x.predicted_per_second.toFixed(1)+'</div>' +
'<div class="muted">tok/s</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s</div>' +
'</div>' +
'</div>' +
'<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>';
} finally {
btn.disabled = false; btn.textContent = '⚡ bench';
rerun.disabled = false;
loadPresets();
}
}
async function switchTo(n,name){
if(!confirm('Basculer vers "'+name+'" et redémarrer le service ?')) 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()},
skill: {label:'Skill', param:'name', getUrl:'/api/skill', saveUrl:'/api/skill/save', delUrl:'/api/skill/delete', reload:()=>loadSkills()},
};
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 delModelWrap = document.getElementById('m-del-model-wrap');
if(kind === 'preset'){
modelRow.style.display = 'block';
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();
delModelWrap.style.display = key ? 'inline-flex' : 'none';
await Promise.all([populateBackendPicker(), populateModelPicker()]);
} else {
modelRow.style.display = 'none';
delModelWrap.style.display = 'none';
}
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 = '<option value="">— choisir un modèle —</option>';
let matched = false;
for(const m of list){
const sel = (m.name === curBase) ? ' selected' : '';
if(sel) matched = true;
html += '<option value="'+m.name+'"'+sel+'>'+m.name+' ('+fmtSize(m.size)+')</option>';
}
// If MODEL points to something not in JEAN_HOME, show it as a disabled hint.
if(cur && !matched){
html += '<option value="" disabled selected>('+(curBase||cur)+' — introuvable dans JEAN_HOME)</option>';
}
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);
}
// Backend picker — same pattern as model: list /etc/jean/backends/*, rewrite BIN=.
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() : '';
}
async function populateBackendPicker(){
const sel = document.getElementById('m-backend');
const list = await jget('/api/backends');
const cur = currentBinInTextarea();
let html = '<option value="">— choisir un backend —</option>';
let matched = false;
for(const b of list){
const isSel = (b.path === cur);
if(isSel) matched = true;
html += '<option value="'+b.path+'"'+(isSel?' selected':'')+'>'+b.name+'</option>';
}
if(cur && !matched){
html += '<option value="" disabled selected>('+(cur.split('/').slice(-3,-2)[0]||cur)+' — hors /etc/jean/backends)</option>';
}
sel.innerHTML = html;
}
function onPickBackend(){
const val = document.getElementById('m-backend').value;
if(!val) return;
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;
}
toast('BIN='+val);
}
// 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');
}
}
const openPreset = (id)=>openItem('preset', id);
const openSkill = (n)=>openItem('skill', 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(!confirm(msg)) 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 = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>'; 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 = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
clearInterval(dlPoll); dlPoll=null; btn.disabled=false; return;
}
if(st.finished){
prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
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.
let stickyBottom = true;
const chatEl = () => document.getElementById('chat');
function isNearBottom(){
const c = chatEl();
return c.scrollHeight - c.scrollTop - c.clientHeight < 60;
}
function scrollMaybe(){
if(stickyBottom){
const c = chatEl();
c.scrollTop = c.scrollHeight;
}
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
}
function jumpBottom(){ stickyBottom = true; scrollMaybe(); }
document.addEventListener('DOMContentLoaded', ()=>{
const c = chatEl();
c.addEventListener('scroll', ()=>{
stickyBottom = isNearBottom();
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
});
});
function addMsg(role, text){
const el=document.createElement('div');
el.className='msg '+role;
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
el.innerHTML='<span class="label">'+role+'</span><div class="body"></div>';
el.querySelector('.body').textContent=text;
chatEl().appendChild(el);
scrollMaybe();
return el;
}
function setLabel(el, text){ el.querySelector('.label').textContent = text; }
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCopyButtons(b); scrollMaybe(); }
// Render a tool call as its own conversation message: the command the model
// wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){
const isShell = tu.name==='run_shell';
setLabel(el, isShell ? 'terminal' : 'skill');
const body=bodyOf(el); body.innerHTML='';
const head=document.createElement('div'); head.className='tool-head';
head.textContent = isShell ? '⚙️ commande' : '📚 skill';
body.appendChild(head);
if(tu.label){
const pre=document.createElement('pre'); pre.className='tool-cmd';
const code=document.createElement('code'); code.textContent=tu.label;
if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
pre.appendChild(code); body.appendChild(pre);
}
const hasResult = tu.result!==undefined && tu.result!=='';
if(hasResult){
const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
body.appendChild(sub);
const pre=document.createElement('pre');
const code=document.createElement('code'); code.textContent=tu.result;
pre.appendChild(code); body.appendChild(pre);
} else if(!tu.done && !tu.typing){
const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='⏳ exécution en cours…';
body.appendChild(wait);
}
addCopyButtons(body); scrollMaybe();
}
// Inject a "copier" button into every <pre> code block (idempotent).
function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{
if(pre.querySelector('.copybtn')) return;
const btn=document.createElement('button');
btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
btn.onclick=async(e)=>{
e.stopPropagation();
const code=pre.querySelector('code'), txt=(code||pre).innerText;
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(); }
btn.textContent='copié ✓'; btn.classList.add('done');
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
};
pre.appendChild(btn);
});
}
function resetChat(){ msgs=[]; document.getElementById('chat').innerHTML=''; saveChat(); try{localStorage.removeItem('jean.ctxused');}catch(e){} setCtxUsed(0); toast('chat vidé'); }
// Compaction : on demande à l'IA un résumé de la conversation destiné à la
// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
async function compactContext(){
if(busy) return;
if(!msgs.length){ toast('rien à compacter'); return; }
if(!confirm('Compacter le contexte ? L\'IA va résumer la conversation puis repartir d\'une session propre basée sur ce résumé.')) return;
busy=true;
const btn=document.getElementById('ctx-compact'); btn.textContent='compaction…'; btn.disabled=true;
const instruction='Résume de façon structurée et complète TOUTE notre conversation jusqu\'ici, dans le but de poursuivre le travail dans une nouvelle session sans rien perdre d\'important : objectifs, décisions prises, état actuel, fichiers/commandes/éléments clés, et ce qu\'il reste à faire. Écris uniquement le résumé, sans préambule.';
let summary='';
try{
const sys=(document.getElementById('sysprompt').value||'').trim();
const base=[...msgs,{role:'user',content:instruction}];
const out = sys ? [{role:'system',content:sys},...base] : base;
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out})});
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
const {done,value}=await reader.read(); if(done) break;
buf+=dec.decode(value,{stream:true}); let i;
while((i=buf.indexOf('\n\n'))>=0){
const chunk=buf.slice(0,i); buf=buf.slice(i+2);
for(const line of chunk.split('\n')){
if(!line.startsWith('data:')) continue;
const data=line.slice(5).trim(); if(data==='[DONE]') continue;
try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; if(d.content) summary+=d.content; }catch(e){}
}
}
}
}catch(e){ toast('échec compaction: '+e.message); busy=false; btn.textContent='compacter'; btn.disabled=false; return; }
summary=summary.trim();
if(!summary){ toast('résumé vide'); busy=false; btn.textContent='compacter'; btn.disabled=false; return; }
// Session propre seedée avec le résumé.
msgs=[{role:'user',content:'Résumé de notre session précédente (reprends le travail à partir de là) :\n\n'+summary},
{role:'assistant',content:'Compris, je reprends à partir de ce résumé.'}];
document.getElementById('chat').innerHTML='';
const note=addMsg('tool',''); note.querySelector('.label').textContent='contexte compacté';
renderBody(note, '🗜️ **Contexte compacté.** Résumé de reprise :\n\n'+summary);
saveChat();
setCtxUsed(0); try{localStorage.removeItem('jean.ctxused');}catch(e){}
busy=false; btn.textContent='compacter'; btn.disabled=false;
toast('contexte compacté');
}
// Persistance de la conversation : on garde user+assistant en localStorage pour
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)); }catch(e){} }
function restoreChat(){
let raw=null; try{ raw=localStorage.getItem('jean.chat'); }catch(e){}
if(!raw) return;
try{ msgs=JSON.parse(raw)||[]; }catch(e){ msgs=[]; return; }
for(const m of msgs){
if(m.role==='user') addMsg('user', m.content);
else if(m.role==='assistant'){ const el=addMsg('assistant',''); renderBody(el, m.content||''); }
}
const cu=parseInt(localStorage.getItem('jean.ctxused')||'0',10); if(cu>0) setCtxUsed(cu);
}
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
let abortCtrl=null;
function stopGen(){ if(abortCtrl){ abortCtrl.abort(); toast('stop'); } }
async function send(){
if(busy) return;
const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return;
ta.value=''; busy=true;
document.getElementById('send').style.display='none';
document.getElementById('stop').style.display='inline-block';
addMsg('user', text);
msgs.push({role:'user',content:text}); saveChat();
let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason='';
let tokCount=0, reasonTokCount=0, t0=performance.now(), firstTok=0, statsTimer=null;
let serverStats=null;
const updateStats=(final)=>{
if(!contentEl && !reasonEl) return;
const el = contentEl || reasonEl;
const role = contentEl ? 'assistant' : 'reasoning';
// Mid-stream: client-side estimate. Final tick: use the server's exact
// timings if it gave us any (more accurate, includes prefill).
if(final && serverStats){
const s = serverStats;
const totalSec = (performance.now() - t0) / 1000;
const parts = [role];
if(s.prompt_tokens) parts.push('prefill ' + s.prompt_tokens + ' tok · ' + s.prompt_per_second.toFixed(0) + ' tok/s');
if(s.gen_tokens) parts.push('decode ' + s.gen_tokens + ' tok · ' + s.gen_per_second.toFixed(1) + ' tok/s');
parts.push('total ' + totalSec.toFixed(1) + 's');
setLabel(el, parts.join(' · '));
return;
}
const n = contentEl ? tokCount : reasonTokCount;
const elapsed = (performance.now() - (firstTok||t0)) / 1000;
const tps = elapsed > 0.05 ? (n / elapsed) : 0;
setLabel(el, role + ' · ' + n + ' tok · ' + tps.toFixed(1) + ' tok/s');
};
abortCtrl = new AbortController();
try{
const sys=(document.getElementById('sysprompt').value||'').trim();
const out = sys ? [{role:'system',content:sys}, ...msgs] : msgs;
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out}),signal:abortCtrl.signal});
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
statsTimer = setInterval(updateStats, 250);
while(true){
const {done,value}=await reader.read(); if(done) break;
buf+=dec.decode(value,{stream:true});
let i;
while((i=buf.indexOf('\n\n'))>=0){
const chunk=buf.slice(0,i); buf=buf.slice(i+2);
for(const line of chunk.split('\n')){
if(!line.startsWith('data:')) continue;
const data=line.slice(5).trim();
if(data==='[DONE]') continue;
try{
const o=JSON.parse(data);
const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{};
if(d.stats){ serverStats = d.stats; }
if(d.tool_used){
// Close out the current assistant/reasoning bubbles so any text the
// model streams *after* the tool call starts a fresh bubble BELOW it
// (and isn't appended above, into the pre-call bubble).
contentEl=null; reasonEl=null;
const tu=d.tool_used;
// One bubble per call: the live "typing" updates, the "en cours"
// spinner, and the final result all re-render the SAME element.
if(!pendingToolEl) pendingToolEl=addMsg('tool','');
renderToolMsg(pendingToolEl, tu);
if(tu.done){
pendingToolEl=null;
// L'IA gère ses skills elle-même : rafraîchir le menu en direct
// dès qu'un skill est créé/modifié/supprimé (pas besoin de F5).
if(tu.name==='skill') loadSkills();
}
}
if(d.reasoning_content){
// Start a fresh buffer per bubble — otherwise a reasoning block that
// follows a tool call re-renders ALL previously accumulated reasoning
// into the new bubble (duplicated dozens of times over a turn).
if(!reasonEl){ reasonEl=addMsg('reasoning',''); fullReason=''; }
if(!firstTok) firstTok=performance.now();
reasonTokCount++;
fullReason+=d.reasoning_content; renderBody(reasonEl, fullReason);
}
if(d.content){
if(!contentEl){ contentEl=addMsg('assistant',''); fullContent=''; firstTok=performance.now(); }
tokCount++;
fullContent+=d.content; renderBody(contentEl, fullContent);
}
}catch(e){}
}
}
}
msgs.push({role:'assistant',content:fullContent}); saveChat();
// Le KV cache s'accumule : chaque tour ajoute les tokens NOUVELLEMENT traités
// (prompt_tokens, le préfixe caché n'est pas recompté) + ceux générés. La
// taille réelle du contexte = somme cumulée, donc monotone croissante.
if(serverStats){ setCtxUsed(CTX_USED + (serverStats.prompt_tokens||0) + (serverStats.gen_tokens||0)); try{ localStorage.setItem('jean.ctxused', CTX_USED); }catch(e){} }
}catch(e){
if(e.name==='AbortError'){
if(contentEl) setLabel(contentEl, 'assistant · '+tokCount+' tok · interrompu');
if(fullContent) msgs.push({role:'assistant',content:fullContent});
else msgs.pop();
} else {
addMsg('assistant','[erreur] '+e.message); msgs.pop();
}
saveChat();
}
if(statsTimer) clearInterval(statsTimer);
updateStats(true);
abortCtrl=null; busy=false;
document.getElementById('send').style.display='inline-block';
document.getElementById('stop').style.display='none';
document.getElementById('input').focus();
}
loadAll();
setInterval(loadStatus, 5000);
setInterval(loadVram, 3000);
</script></body></html>