mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
- 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.
902 lines
52 KiB
HTML
902 lines
52 KiB
HTML
<!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,'"')+'">'+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>
|