k3-a40-bootstrap / console.html
patdev's picture
v26: console HTTP servie par le pont
2e6c32d verified
Raw
History Blame Contribute Delete
10.9 kB
<title>Console vLLM</title>
<style>
:root{
--bg:#fbfbfa; --panel:#fff; --ink:#1c1b19; --muted:#6b6963; --line:#e5e3de;
--accent:#a8543a; --accent-soft:#f4ece8; --think:#f5f2ee; --tool:#eef3f0;
--tool-line:#b9cfc3; --warn:#8a5a1f;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#17161a; --panel:#1e1d22; --ink:#eceae6; --muted:#9a978f; --line:#302e35;
--accent:#d98b6c; --accent-soft:#2a2028; --think:#232128; --tool:#1c2622;
--tool-line:#3a5648; --warn:#d0a260;
}
}
:root[data-theme="dark"]{
--bg:#17161a; --panel:#1e1d22; --ink:#eceae6; --muted:#9a978f; --line:#302e35;
--accent:#d98b6c; --accent-soft:#2a2028; --think:#232128; --tool:#1c2622;
--tool-line:#3a5648; --warn:#d0a260;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:15px/1.55 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif;
display:flex;flex-direction:column;height:100vh;overflow:hidden}
header{display:flex;gap:10px;align-items:center;flex-wrap:wrap;
padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
h1{font-size:15px;margin:0 8px 0 0;font-weight:650;letter-spacing:-.01em}
select,input,textarea,button{font:inherit;color:var(--ink);background:var(--panel);
border:1px solid var(--line);border-radius:7px;padding:5px 9px}
input[type=number]{width:82px}
button{cursor:pointer;background:var(--accent);color:#fff;border-color:transparent;font-weight:600}
button.ghost{background:transparent;color:var(--muted);font-weight:500}
button:disabled{opacity:.45;cursor:default}
label{font-size:12px;color:var(--muted);display:flex;gap:5px;align-items:center}
#stats{margin-left:auto;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
color:var(--muted);text-align:right;white-space:nowrap}
#stats b{color:var(--accent);font-weight:650}
#syswrap{padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel)}
#syswrap[hidden]{display:none}
#sys{width:100%;height:74px;margin:10px 0;resize:vertical;font-size:13px;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
#log{flex:1;overflow-y:auto;padding:18px 16px;display:flex;flex-direction:column;gap:14px}
.msg{max-width:min(860px,100%);width:fit-content;padding:10px 14px;border-radius:12px;
border:1px solid var(--line);background:var(--panel);white-space:pre-wrap;
overflow-wrap:anywhere}
.user{align-self:flex-end;background:var(--accent-soft);border-color:transparent}
.role{font-size:11px;text-transform:uppercase;letter-spacing:.07em;
color:var(--muted);margin-bottom:5px;font-weight:600}
details.think{background:var(--think);border:1px solid var(--line);border-radius:10px;
padding:7px 12px;margin:0 0 9px;font-size:13px;color:var(--muted)}
details.think summary{cursor:pointer;font-size:11px;text-transform:uppercase;
letter-spacing:.07em;font-weight:600}
details.think div{white-space:pre-wrap;margin-top:7px}
.tool{background:var(--tool);border:1px solid var(--tool-line);border-radius:10px;
padding:9px 12px;margin:9px 0 0;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
overflow-x:auto}
.tool .n{color:var(--accent);font-weight:700}
.tool pre{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}
.err{border-color:var(--warn);color:var(--warn)}
footer{display:flex;gap:9px;padding:12px 16px;border-top:1px solid var(--line);
background:var(--panel)}
#q{flex:1;height:60px;resize:vertical;font-size:14px}
.hint{font-size:11px;color:var(--muted);padding:0 16px 9px;background:var(--panel)}
</style>
<header>
<h1>Console vLLM</h1>
<select id="model"></select>
<label>max <input type="number" id="maxtok" value="4096" min="1" max="200000"></label>
<label>temp <input type="number" id="temp" value="0" min="0" max="2" step="0.1"></label>
<label><input type="checkbox" id="think" checked> raisonnement</label>
<button class="ghost" id="togsys">système</button>
<button class="ghost" id="clear">effacer</button>
<div id="stats">prêt</div>
</header>
<div id="syswrap" hidden>
<textarea id="sys" placeholder="Prompt système (laisser vide pour aucun)…"></textarea>
</div>
<div id="log"></div>
<div class="hint">Entrée pour envoyer · Maj+Entrée pour un saut de ligne · le débit est mesuré entre le premier et le dernier jeton</div>
<footer>
<textarea id="q" placeholder="Votre message…"></textarea>
<button id="send">Envoyer</button>
<button class="ghost" id="stop" disabled>Stop</button>
</footer>
<script>
const $ = s => document.querySelector(s);
const log = $('#log');
let history = []; // messages Anthropic
let ctrl = null;
function el(tag, cls, txt){
const e = document.createElement(tag);
if (cls) e.className = cls;
if (txt != null) e.textContent = txt;
return e;
}
function bubble(role, cls){
const d = el('div', 'msg ' + (cls || ''));
d.appendChild(el('div', 'role', role));
log.appendChild(d);
log.scrollTop = log.scrollHeight;
return d;
}
// Les identifiants viennent de /v1/models. On envoie l'en-tete anthropic-version
// pour obtenir les alias claude-*, seuls acceptes par les clients Anthropic.
fetch('/v1/models', {headers:{'anthropic-version':'2023-06-01'}})
.then(r => r.json())
.then(d => {
const sel = $('#model');
(d.data || []).forEach(m => sel.appendChild(new Option(m.id, m.id)));
const ctx = (d.data || [])[0] && (d.data || [])[0].context_window;
if (ctx) $('#stats').textContent = 'contexte ' + ctx.toLocaleString('fr');
})
.catch(() => { $('#stats').textContent = 'modèles injoignables'; });
$('#togsys').onclick = () => { $('#syswrap').hidden = !$('#syswrap').hidden; };
$('#clear').onclick = () => { history = []; log.innerHTML = ''; $('#stats').textContent = 'prêt'; };
$('#stop').onclick = () => { if (ctrl) ctrl.abort(); };
$('#q').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
$('#send').onclick = send;
async function send(){
const text = $('#q').value.trim();
if (!text || ctrl) return;
$('#q').value = '';
history.push({role:'user', content:text});
bubble('vous', 'user').appendChild(el('div', null, text));
const out = bubble($('#model').value || 'assistant');
const body = out.appendChild(el('div'));
let think = null, thinkBody = null;
const blocks = {}; // index -> {type,name,node,buf}
const body_req = {
model: $('#model').value,
max_tokens: +$('#maxtok').value,
temperature: +$('#temp').value,
stream: true,
messages: history,
};
const sys = $('#sys').value.trim();
if (sys) body_req.system = sys;
// Le raisonnement est actif par defaut cote modele ; il consomme des jetons
// avant le premier caractere de reponse, ce qui pese sur les tours courts.
if (!$('#think').checked) body_req.thinking = {type:'disabled'};
ctrl = new AbortController();
$('#send').disabled = true; $('#stop').disabled = false;
const t0 = performance.now();
let tFirst = null, chars = 0, usage = null, stop = null;
try {
const r = await fetch('/v1/messages', {
method:'POST', signal: ctrl.signal,
headers:{'Content-Type':'application/json','anthropic-version':'2023-06-01',
'Accept':'text/event-stream'},
body: JSON.stringify(body_req),
});
if (!r.ok) throw new Error('HTTP ' + r.status + ' ' + (await r.text()).slice(0,300));
const rd = r.body.getReader(), dec = new TextDecoder();
let buf = '';
for(;;){
const {done, value} = await rd.read();
if (done) break;
buf += dec.decode(value, {stream:true});
const parts = buf.split('\n');
buf = parts.pop();
for (const line of parts){
if (!line.startsWith('data:')) continue;
let d; try { d = JSON.parse(line.slice(5).trim()); } catch { continue; }
if (d.type === 'content_block_start'){
const cb = d.content_block || {};
if (cb.type === 'tool_use'){
const n = el('div','tool');
n.appendChild(el('span','n', '⚙ ' + (cb.name || 'outil')));
const pre = n.appendChild(el('pre'));
body.parentNode.appendChild(n);
blocks[d.index] = {type:'tool_use', node:pre, buf:''};
} else if (cb.type === 'thinking'){
blocks[d.index] = {type:'thinking'};
} else {
blocks[d.index] = {type:'text'};
}
}
else if (d.type === 'content_block_delta'){
const dl = d.delta || {}, b = blocks[d.index] || {type:'text'};
if (tFirst === null) tFirst = performance.now();
if (dl.type === 'input_json_delta'){
b.buf = (b.buf || '') + dl.partial_json;
if (b.node) b.node.textContent = b.buf;
chars += dl.partial_json.length;
} else if (dl.type === 'thinking_delta' || b.type === 'thinking'){
if (!think){
think = el('details','think');
think.appendChild(el('summary','', 'raisonnement'));
thinkBody = think.appendChild(el('div'));
body.parentNode.insertBefore(think, body);
}
thinkBody.textContent += (dl.thinking || dl.text || '');
} else if (dl.text){
body.textContent += dl.text;
chars += dl.text.length;
}
log.scrollTop = log.scrollHeight;
}
else if (d.type === 'message_delta'){
usage = d.usage || usage;
stop = (d.delta || {}).stop_reason || stop;
}
}
}
// On rejoue la reponse dans l'historique pour que le tour suivant en herite.
const content = [];
if (body.textContent) content.push({type:'text', text: body.textContent});
for (const k of Object.keys(blocks)){
const b = blocks[k];
if (b.type === 'tool_use' && b.buf){
try { content.push({type:'text', text:'[appel outil] ' + b.buf}); } catch {}
}
}
if (content.length) history.push({role:'assistant', content});
const dt = (performance.now() - t0) / 1000;
const gen = tFirst ? (performance.now() - tFirst) / 1000 : dt;
const toks = usage && usage.output_tokens ? usage.output_tokens : Math.round(chars / 4);
const tps = gen > 0 ? (toks / gen) : 0;
$('#stats').innerHTML =
`<b>${tps.toFixed(1)} tok/s</b> · ${toks} jetons · 1er ${(tFirst ? (tFirst - t0) / 1000 : 0).toFixed(1)}s`
+ ` · total ${dt.toFixed(1)}s` + (stop ? ` · ${stop}` : '')
+ (usage && usage.input_tokens ? ` · entrée ${usage.input_tokens}` : '');
} catch (e){
const n = bubble('erreur','err');
n.appendChild(el('div', null, String(e.message || e)));
} finally {
ctrl = null;
$('#send').disabled = false; $('#stop').disabled = true;
$('#q').focus();
}
}
$('#q').focus();
</script>