a14e3ade0b
A clear chat page (markdown, code blocks with copy, stop, new chat, kept in the browser, drafts shown as such) and a monitor sidebar, a sheet on phones: tokens/s, a live map of the 30 x 128 experts (in VRAM, used now, copied in), VRAM hit rate, copies, context, prompt cache, GPU VRAM/load/temperature/power/fan. GET /status merges vLLM /metrics, amdgpu sysfs and the stats file, which now lists the experts in VRAM. /warm answers 202 when a warm-up starts. README: the int4 kernels, their A/B, 2048-token chunks as a trade, prompt logprobs limit.
717 lines
34 KiB
HTML
717 lines
34 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta name="color-scheme" content="light dark">
|
||
<title>prain</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f1f3f6; --surface: #fff; --ink: #161b22; --muted: #5b6673; --faint: #8a94a0;
|
||
--line: #dde2e8; --bubble: #e5e9ef; --code: #f6f7f9; --focus: #7c4dcc; --live: #f26b1d;
|
||
--ok: #23946a; --bad: #c2362f;
|
||
--ui: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Cantarell, sans-serif;
|
||
--display: ui-rounded, "SF Pro Rounded", Nunito, system-ui, -apple-system, sans-serif;
|
||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", "DejaVu Sans Mono", Menlo, Consolas, monospace;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root { --bg: #0f1216; --surface: #161a20; --ink: #e6eaf0; --muted: #9aa4b1; --faint: #6d7784;
|
||
--line: #262d36; --bubble: #232a34; --code: #1b2027; --focus: #a58bea; }
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; margin: 0; }
|
||
body { background: var(--bg); color: var(--ink); font: 16px/1.6 var(--ui); -webkit-font-smoothing: antialiased; }
|
||
button { font: inherit; color: inherit; }
|
||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||
[hidden] { display: none !important; }
|
||
|
||
.app { display: grid; grid-template-columns: minmax(0, 1fr) 340px; height: 100dvh; }
|
||
.chat { display: flex; flex-direction: column; min-width: 0; height: 100dvh; }
|
||
|
||
/* header */
|
||
.bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px; }
|
||
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||
.mark { display: grid; grid-template-columns: repeat(4, 6px); gap: 2px; }
|
||
.mark i { width: 6px; height: 6px; border-radius: 2px; background: var(--line); transition: background .3s; }
|
||
.mark i.on { background: var(--live); }
|
||
.word { font: 700 20px/1 var(--display); letter-spacing: -.01em; }
|
||
.model { font: 13px var(--mono); color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.bar-actions { display: flex; gap: 8px; align-items: center; }
|
||
.ghost { border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 6px 14px; font-size: 14px; cursor: pointer; }
|
||
.ghost:hover { border-color: var(--faint); }
|
||
.pill { display: none; align-items: center; gap: 8px; }
|
||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
||
.dot.ok { background: var(--ok); }
|
||
.dot.live { background: var(--live); animation: pulse 1.2s ease-in-out infinite; }
|
||
.dot.bad { background: var(--bad); }
|
||
@keyframes pulse { 50% { opacity: .35; } }
|
||
|
||
/* thread */
|
||
.log { flex: 1; overflow-y: auto; padding: 8px 20px 24px; overscroll-behavior: contain; }
|
||
.thread { max-width: 760px; margin: 0 auto; }
|
||
.empty { max-width: 560px; margin: 14vh auto 0; }
|
||
.empty h1 { font: 650 clamp(28px, 4.5vw, 38px)/1.15 var(--display); letter-spacing: -.015em; margin: 0 0 10px; }
|
||
.empty p { color: var(--muted); margin: 0 0 6px; }
|
||
.empty .live-line { font-size: 14px; color: var(--faint); margin-bottom: 22px; font-variant-numeric: tabular-nums; }
|
||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; }
|
||
.chip { border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 8px 14px; font-size: 14px; cursor: pointer; text-align: left; }
|
||
.chip:hover { border-color: var(--faint); }
|
||
|
||
.msg { margin: 22px 0; }
|
||
.msg.user { display: flex; justify-content: flex-end; }
|
||
.bubble { background: var(--bubble); padding: 10px 16px; border-radius: 20px 20px 6px 20px; max-width: min(85%, 600px); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
.body { overflow-wrap: anywhere; }
|
||
.body > :first-child { margin-top: 0; }
|
||
.body > :last-child { margin-bottom: 0; }
|
||
.body p { margin: 0 0 .8em; }
|
||
.body h3, .body h4, .body h5 { font: 650 1.08em/1.3 var(--display); margin: 1.3em 0 .4em; }
|
||
.body ul, .body ol { padding-left: 1.4em; margin: 0 0 .8em; }
|
||
.body li { margin: .25em 0; }
|
||
.body blockquote { border-left: 3px solid var(--line); margin: .8em 0; padding-left: 12px; color: var(--muted); }
|
||
.body hr { border: 0; border-top: 1px solid var(--line); margin: 1.2em 0; }
|
||
.body a { color: var(--focus); }
|
||
.body code { font: .88em var(--mono); background: var(--code); border: 1px solid var(--line); padding: .05em .35em; border-radius: 6px; }
|
||
.code { border: 1px solid var(--line); background: var(--code); border-radius: 12px; margin: .9em 0; overflow: hidden; }
|
||
.code-head { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px 4px 14px; border-bottom: 1px solid var(--line); font: 12px var(--mono); color: var(--faint); }
|
||
.code pre { margin: 0; padding: 12px 14px; overflow-x: auto; font: 13.5px/1.55 var(--mono); tab-size: 4; }
|
||
.code code { background: none; border: 0; padding: 0; font-size: inherit; }
|
||
.table { overflow-x: auto; margin: .9em 0; }
|
||
.body table { border-collapse: collapse; font-size: 14px; }
|
||
.body th, .body td { border-bottom: 1px solid var(--line); padding: 6px 12px 6px 0; text-align: left; vertical-align: top; }
|
||
.small { border: 0; background: none; border-radius: 6px; padding: 3px 8px; font-size: 12.5px; color: var(--faint); cursor: pointer; }
|
||
.small:hover { color: var(--ink); background: var(--bubble); }
|
||
.meta { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: center; margin-top: 6px; font-size: 12.5px; color: var(--faint); font-variant-numeric: tabular-nums; }
|
||
.note { display: inline-block; font-size: 13px; color: var(--muted); background: var(--bubble); border-radius: 10px; padding: 5px 10px; margin-bottom: 10px; }
|
||
.note.bad { color: var(--bad); }
|
||
.is-draft .body, .rest { color: var(--faint); }
|
||
.typing::after { content: ""; display: inline-block; width: .5em; height: 1.05em; margin-left: 3px; vertical-align: -.18em; border-radius: 2px; background: var(--live); animation: pulse 1s ease-in-out infinite; }
|
||
|
||
/* composer */
|
||
.composer { padding: 0 20px calc(14px + env(safe-area-inset-bottom)); }
|
||
.field { max-width: 760px; margin: 0 auto; display: flex; align-items: flex-end; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 22px; padding: 7px 7px 7px 16px; box-shadow: 0 10px 30px -18px rgb(20 30 45 / .35); }
|
||
.field:focus-within { border-color: color-mix(in srgb, var(--focus) 50%, var(--line)); }
|
||
textarea { flex: 1; border: 0; outline: 0; resize: none; background: transparent; color: inherit; font: 16px/1.5 var(--ui); max-height: 40vh; padding: 6px 0; }
|
||
.send { width: 36px; height: 36px; flex: none; border-radius: 50%; border: 0; background: var(--ink); color: var(--surface); display: grid; place-items: center; cursor: pointer; }
|
||
.send:disabled { opacity: .3; cursor: default; }
|
||
.send svg { width: 18px; height: 18px; }
|
||
.hint { max-width: 760px; margin: 6px auto 0; min-height: 18px; padding: 0 16px; font-size: 12.5px; color: var(--faint); display: flex; align-items: center; gap: 8px; }
|
||
.cells { display: inline-grid; grid-template-columns: repeat(8, 5px); gap: 2px; }
|
||
.cells i { width: 5px; height: 5px; border-radius: 1.5px; background: var(--live); animation: pulse 1s ease-in-out infinite; }
|
||
.cells i:nth-child(2n) { animation-delay: .25s; } .cells i:nth-child(3n) { animation-delay: .5s; }
|
||
|
||
/* monitor */
|
||
.monitor { background: var(--surface); border-left: 1px solid var(--line); overflow-y: auto; padding: 18px 20px 28px; font-size: 14px; }
|
||
.m-top { display: flex; justify-content: space-between; align-items: start; }
|
||
.m-title { font: 650 17px/1.2 var(--display); }
|
||
.m-sub { font: 12.5px var(--mono); color: var(--faint); margin-top: 2px; }
|
||
.close { display: none; }
|
||
.state { display: flex; align-items: center; gap: 8px; margin: 14px 0 6px; color: var(--muted); }
|
||
.speed { display: flex; align-items: baseline; gap: 6px; }
|
||
.speed b { font: 650 40px/1 var(--display); letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
|
||
.speed.idle b { color: var(--faint); }
|
||
.speed span { color: var(--faint); }
|
||
.spark { width: 100%; height: 28px; margin: 6px 0 16px; display: block; }
|
||
.spark path { fill: none; stroke: var(--live); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
|
||
.scan { background: #16121d; color: #c9c1d6; border-radius: 14px; padding: 12px 12px 10px; }
|
||
.scan-head { display: flex; justify-content: space-between; font-size: 12.5px; margin-bottom: 8px; font-variant-numeric: tabular-nums; }
|
||
.scan-head b { font-weight: 600; color: #f2edf8; }
|
||
.scan canvas { display: block; width: 100%; aspect-ratio: 128 / 64; image-rendering: pixelated; border-radius: 4px; background: #2c2538; }
|
||
.axis { display: flex; justify-content: space-between; font: 11px var(--mono); color: #8f86a0; margin-top: 4px; }
|
||
.legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 8px 0 0; padding: 0; list-style: none; font-size: 12px; }
|
||
.legend i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
|
||
.scan-empty { font-size: 12.5px; color: #8f86a0; padding: 18px 4px; }
|
||
.kv { margin: 16px 0 0; }
|
||
.kv div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--line); }
|
||
.kv dt { color: var(--muted); }
|
||
.kv dd { margin: 0; font-variant-numeric: tabular-nums; text-align: right; }
|
||
.kv small { color: var(--faint); }
|
||
.meter { display: block; width: 84px; height: 4px; border-radius: 2px; background: var(--bubble); margin-top: 5px; margin-left: auto; overflow: hidden; }
|
||
.meter i { display: block; height: 100%; width: 0; background: var(--ink); transition: width .4s; }
|
||
.m-h { font: 650 13px/1 var(--display); color: var(--faint); text-transform: uppercase; letter-spacing: .06em; margin: 24px 0 0; }
|
||
.scrim { display: none; }
|
||
|
||
@media (max-width: 999px) {
|
||
.app { grid-template-columns: 1fr; }
|
||
.pill, .close { display: inline-flex; }
|
||
.monitor { position: fixed; top: 0; right: 0; bottom: 0; width: min(360px, 92vw); z-index: 20; transform: translateX(100%); transition: transform .25s ease; box-shadow: -20px 0 40px -20px rgb(0 0 0 / .35); padding-top: calc(18px + env(safe-area-inset-top)); }
|
||
.monitor.open { transform: none; }
|
||
.scrim.open { display: block; position: fixed; inset: 0; z-index: 10; background: rgb(10 14 20 / .35); }
|
||
}
|
||
@media (max-width: 560px) {
|
||
.bar { padding: 10px 14px; } .log { padding: 6px 14px 18px; } .composer { padding-left: 10px; padding-right: 10px; }
|
||
.model, .hint .keys { display: none; } .empty { margin-top: 8vh; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::after { animation: none !important; transition: none !important; }
|
||
}
|
||
</style>
|
||
|
||
<div class="app">
|
||
<main class="chat">
|
||
<header class="bar">
|
||
<div class="brand">
|
||
<span class="mark" id="mark" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
||
<span class="word">prain</span>
|
||
<span class="model" id="model"></span>
|
||
</div>
|
||
<div class="bar-actions">
|
||
<button class="ghost pill" id="pill" aria-controls="monitor" aria-expanded="false">
|
||
<span class="dot" id="pill-dot"></span><span id="pill-text">Monitor</span></button>
|
||
<button class="ghost" id="new-chat">New chat</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="log" id="log" role="log">
|
||
<div class="thread" id="thread">
|
||
<div class="empty" id="empty">
|
||
<h1>Ready when you are</h1>
|
||
<p>prain moves the experts your message needs into VRAM while you type, so the answer starts sooner.</p>
|
||
<div class="live-line" id="empty-live"></div>
|
||
<div class="chips">
|
||
<button class="chip">Explain what git rebase -i HEAD~3 does</button>
|
||
<button class="chip">Write a bash one-liner that counts lines of code per file type</button>
|
||
<button class="chip">On Gentoo, how do I rebuild the packages affected by a USE flag change?</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<form class="composer" id="composer">
|
||
<div class="field">
|
||
<textarea id="draft" rows="1" placeholder="Message prain" aria-label="Message"></textarea>
|
||
<button class="send" id="send" type="submit" aria-label="Send" disabled>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path id="send-icon" fill="currentColor" d="M12 4l7 7-1.4 1.4-4.6-4.6V20h-2V7.8l-4.6 4.6L5 11z"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="hint">
|
||
<span id="warm" hidden><span class="cells" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span> Loading experts for your message</span>
|
||
<span class="keys" id="keys">Enter sends · Shift+Enter adds a line</span>
|
||
</div>
|
||
</form>
|
||
</main>
|
||
|
||
<div class="scrim" id="scrim"></div>
|
||
<aside class="monitor" id="monitor" aria-label="Model monitor">
|
||
<div class="m-top">
|
||
<div><div class="m-title">Monitor</div><div class="m-sub" id="m-model">connecting…</div></div>
|
||
<button class="ghost close" id="close" aria-label="Close the monitor">Close</button>
|
||
</div>
|
||
<div class="state"><span class="dot" id="dot"></span><span id="state">Connecting…</span></div>
|
||
<div class="speed idle" id="speed-box"><b id="speed">–</b><span id="speed-unit">tokens/s</span></div>
|
||
<svg class="spark" viewBox="0 0 60 28" preserveAspectRatio="none" aria-hidden="true"><path id="spark"/></svg>
|
||
|
||
<section class="scan" aria-label="Experts by layer">
|
||
<div class="scan-head"><span>Experts</span><span id="scan-count"></span></div>
|
||
<div id="scan-body">
|
||
<canvas id="scan" width="128" height="30" role="img" aria-label="Expert map"></canvas>
|
||
<div class="axis"><span>layer 1 · expert 1</span><span id="axis-end"></span></div>
|
||
<ul class="legend">
|
||
<li><i style="background:#6a35a0"></i>In VRAM</li>
|
||
<li><i style="background:#ff8a2a"></i>Used now</li>
|
||
<li><i style="background:#45d3c7"></i>Copied in</li>
|
||
</ul>
|
||
</div>
|
||
<div class="scan-empty" id="scan-empty" hidden>The map appears once prain writes expert stats: set stats_path in the placement file and pass it with --config.</div>
|
||
</section>
|
||
|
||
<dl class="kv">
|
||
<div><dt>VRAM hits</dt><dd id="hits">–</dd></div>
|
||
<div><dt>Copies</dt><dd id="copies">–</dd></div>
|
||
<div><dt>Context in use</dt><dd><span id="kv">–</span><span class="meter"><i id="kv-bar"></i></span></dd></div>
|
||
<div><dt>Prompt cache reuse</dt><dd id="prefix">–</dd></div>
|
||
<div><dt>Requests</dt><dd id="reqs">–</dd></div>
|
||
</dl>
|
||
<h3 class="m-h">GPU</h3>
|
||
<dl class="kv">
|
||
<div><dt>VRAM</dt><dd><span id="vram">–</span><span class="meter"><i id="vram-bar"></i></span></dd></div>
|
||
<div><dt>Load</dt><dd id="load">–</dd></div>
|
||
<div><dt>Temperature</dt><dd id="temp">–</dd></div>
|
||
<div><dt>Power</dt><dd id="power">–</dd></div>
|
||
<div><dt>Fan</dt><dd id="fan">–</dd></div>
|
||
</dl>
|
||
</aside>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const log = $("log"), thread = $("thread"), draft = $("draft"), send = $("send");
|
||
const coarse = matchMedia("(pointer: coarse)").matches;
|
||
const newId = () => Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||
let chat = load() || {session: newId(), messages: []};
|
||
let streaming = false, controller = null;
|
||
|
||
function load() {
|
||
try { return JSON.parse(localStorage.getItem("prain.chat")); } catch { return null; }
|
||
}
|
||
function save() {
|
||
try { localStorage.setItem("prain.chat", JSON.stringify(chat)); } catch {}
|
||
}
|
||
const plain = () => chat.messages.map(({role, content}) => ({role, content}));
|
||
|
||
/* ---------- markdown: escape first, then a small safe subset ---------- */
|
||
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
|
||
function inline(text) {
|
||
return text.split(/(`[^`\n]+`)/g).map((part, i) => i % 2
|
||
? `<code>${esc(part.slice(1, -1))}</code>`
|
||
: esc(part)
|
||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||
.replace(/(^|[^*\w])\*([^*\s][^*]*)\*(?![*\w])/g, "$1<em>$2</em>")
|
||
).join("");
|
||
}
|
||
const ITEM = /^\s*([-*+]|\d+[.)])\s+/;
|
||
function md(src) {
|
||
const lines = src.split("\n"), out = [];
|
||
let i = 0, para = [];
|
||
const flush = () => { if (para.length) out.push(`<p>${para.map(inline).join("<br>")}</p>`); para = []; };
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
let m;
|
||
if ((m = line.match(/^\s*```\s*([\w+#.-]*)/))) {
|
||
flush();
|
||
const code = [];
|
||
for (i++; i < lines.length && !/^\s*```\s*$/.test(lines[i]); i++) code.push(lines[i]);
|
||
i++;
|
||
out.push(`<div class="code"><div class="code-head"><span>${esc(m[1] || "code")}</span>` +
|
||
`<button class="small" data-copy>Copy</button></div><pre><code>${esc(code.join("\n"))}</code></pre></div>`);
|
||
} else if ((m = line.match(/^(#{1,6})\s+(.*)/))) {
|
||
flush();
|
||
const level = Math.min(m[1].length + 2, 5);
|
||
out.push(`<h${level}>${inline(m[2])}</h${level}>`);
|
||
i++;
|
||
} else if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1] || "")) {
|
||
flush();
|
||
const rows = [];
|
||
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(lines[i++]);
|
||
const cells = (row, tag) => row.trim().replace(/^\||\|$/g, "").split("|")
|
||
.map((c) => `<${tag}>${inline(c.trim())}</${tag}>`).join("");
|
||
out.push(`<div class="table"><table><thead><tr>${cells(rows[0], "th")}</tr></thead><tbody>` +
|
||
rows.slice(2).map((r) => `<tr>${cells(r, "td")}</tr>`).join("") + "</tbody></table></div>");
|
||
} else if (ITEM.test(line)) {
|
||
flush();
|
||
const ordered = /^\s*\d/.test(line), items = [];
|
||
const start = ordered ? parseInt(line.trim(), 10) : 1;
|
||
while (i < lines.length && (ITEM.test(lines[i]) || (items.length && /^\s{2,}\S/.test(lines[i])))) {
|
||
if (ITEM.test(lines[i])) items.push(lines[i].replace(ITEM, ""));
|
||
else items[items.length - 1] += " " + lines[i].trim();
|
||
i++;
|
||
}
|
||
const tag = ordered ? "ol" : "ul";
|
||
out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ""}>` +
|
||
items.map((t) => `<li>${inline(t)}</li>`).join("") + `</${tag}>`);
|
||
} else if (/^\s*>/.test(line)) {
|
||
flush();
|
||
const quote = [];
|
||
while (i < lines.length && /^\s*>/.test(lines[i])) quote.push(lines[i++].replace(/^\s*>\s?/, ""));
|
||
out.push(`<blockquote>${md(quote.join("\n"))}</blockquote>`);
|
||
} else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
||
flush();
|
||
out.push("<hr>");
|
||
i++;
|
||
} else if (!line.trim()) {
|
||
flush();
|
||
i++;
|
||
} else {
|
||
para.push(line);
|
||
i++;
|
||
}
|
||
}
|
||
flush();
|
||
return out.join("");
|
||
}
|
||
|
||
/* ---------- thread ---------- */
|
||
function nearBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight < 120; }
|
||
function toBottom() { log.scrollTop = log.scrollHeight; }
|
||
function el(tag, cls, text) {
|
||
const node = document.createElement(tag);
|
||
if (cls) node.className = cls;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
function addUser(text) {
|
||
const row = el("div", "msg user");
|
||
row.appendChild(el("div", "bubble", text));
|
||
thread.appendChild(row);
|
||
}
|
||
function addBot() {
|
||
const row = el("div", "msg bot");
|
||
const view = {row, note: el("div", "note"), body: el("div", "body"), meta: el("div", "meta")};
|
||
view.note.hidden = true;
|
||
view.meta.hidden = true;
|
||
row.append(view.note, view.body, view.meta);
|
||
thread.appendChild(row);
|
||
return view;
|
||
}
|
||
function paint(view, text, rest, live) {
|
||
view.body.innerHTML = md(text);
|
||
if (rest) {
|
||
const target = view.body.lastElementChild && view.body.lastElementChild.tagName === "P"
|
||
? view.body.lastElementChild : view.body;
|
||
target.appendChild(el("span", "rest", rest));
|
||
}
|
||
view.body.classList.toggle("typing", live);
|
||
}
|
||
function finish(view, message) {
|
||
view.meta.hidden = false;
|
||
view.meta.replaceChildren(el("span", "", message.meta || ""));
|
||
const copy = el("button", "small", "Copy");
|
||
copy.addEventListener("click", () => copied(copy, message.content));
|
||
view.meta.appendChild(copy);
|
||
}
|
||
function render() {
|
||
thread.querySelectorAll(".msg").forEach((n) => n.remove());
|
||
$("empty").hidden = chat.messages.length > 0;
|
||
for (const m of chat.messages) {
|
||
if (m.role === "user") { addUser(m.content); continue; }
|
||
const view = addBot();
|
||
view.row.classList.toggle("is-draft", !!m.draft);
|
||
paint(view, m.content, "", false);
|
||
finish(view, m);
|
||
}
|
||
toBottom();
|
||
}
|
||
|
||
async function copyText(text) {
|
||
try { await navigator.clipboard.writeText(text); return true; } catch {}
|
||
const area = el("textarea"); // plain http: no Clipboard API
|
||
area.value = text;
|
||
area.setAttribute("readonly", "");
|
||
area.style.cssText = "position:fixed;top:0;opacity:0";
|
||
document.body.appendChild(area);
|
||
area.select();
|
||
area.setSelectionRange(0, text.length);
|
||
let ok = false;
|
||
try { ok = document.execCommand("copy"); } catch {}
|
||
area.remove();
|
||
return ok;
|
||
}
|
||
async function copied(button, text) {
|
||
const ok = await copyText(text);
|
||
button.textContent = ok ? "Copied" : "Copy failed";
|
||
setTimeout(() => (button.textContent = "Copy"), 1500);
|
||
}
|
||
thread.addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-copy]");
|
||
if (button) copied(button, button.closest(".code").querySelector("pre").textContent);
|
||
const chip = event.target.closest(".chip");
|
||
if (chip) { draft.value = chip.textContent; draft.focus(); draft.dispatchEvent(new Event("input")); }
|
||
});
|
||
|
||
/* ---------- sending ---------- */
|
||
const ICON_SEND = "M12 4l7 7-1.4 1.4-4.6-4.6V20h-2V7.8l-4.6 4.6L5 11z";
|
||
const ICON_STOP = "M7 7h10v10H7z";
|
||
function setStreaming(on) {
|
||
streaming = on;
|
||
log.setAttribute("aria-busy", on);
|
||
$("send-icon").setAttribute("d", on ? ICON_STOP : ICON_SEND);
|
||
send.setAttribute("aria-label", on ? "Stop" : "Send");
|
||
send.disabled = !on && !draft.value.trim();
|
||
if (!on) lightMark(0);
|
||
}
|
||
function lightMark(n) {
|
||
const cells = [...$("mark").children];
|
||
cells.forEach((c) => c.classList.remove("on"));
|
||
for (let k = 0; k < n; k++) cells[Math.floor(Math.random() * cells.length)].classList.add("on");
|
||
}
|
||
|
||
async function submit() {
|
||
const text = draft.value.trim();
|
||
if (streaming || !text) return;
|
||
const conv = chat; // "New chat" mid-answer must not receive this answer
|
||
$("empty").hidden = true;
|
||
conv.messages.push({role: "user", content: text});
|
||
addUser(text);
|
||
save();
|
||
draft.value = "";
|
||
autosize();
|
||
hideWarm();
|
||
setStreaming(true);
|
||
toBottom();
|
||
const view = addBot();
|
||
const st = {mode: "full", draft: "", final: "", refined: false};
|
||
let start = performance.now(), first = 0, pieces = 0, stopped = false, failure = null;
|
||
const show = () => {
|
||
const follow = nearBottom();
|
||
const text = st.mode === "draft" ? st.draft : st.final;
|
||
const rest = st.mode === "refine" && st.draft.startsWith(st.final) ? st.draft.slice(st.final.length) : "";
|
||
view.row.classList.toggle("is-draft", st.mode === "draft");
|
||
paint(view, text, rest, true);
|
||
if (follow) toBottom();
|
||
};
|
||
const note = (text, bad) => { view.note.hidden = !text; view.note.textContent = text || ""; view.note.classList.toggle("bad", !!bad); };
|
||
controller = new AbortController();
|
||
try {
|
||
const response = await fetch("/chat", {method: "POST", signal: controller.signal,
|
||
body: JSON.stringify({session: chat.session, messages: plain()})});
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
|
||
const reader = response.body.getReader(), decoder = new TextDecoder();
|
||
let buffer = "";
|
||
show();
|
||
for (;;) {
|
||
const {done, value} = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, {stream: true});
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop();
|
||
for (const line of lines) {
|
||
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
||
const data = JSON.parse(line.slice(6));
|
||
if (data.prain === "draft") {
|
||
st.mode = "draft";
|
||
note("Another answer is using the GPU, so this is a draft from the experts already in VRAM.");
|
||
} else if (data.prain === "refine") {
|
||
st.mode = "refine";
|
||
st.refined = true;
|
||
start = performance.now(), first = 0, pieces = 0;
|
||
note("Refining the draft with all experts…");
|
||
} else if (data.prain === "error") {
|
||
st.mode = "draft";
|
||
st.refined = false;
|
||
note(`Refining failed (HTTP ${data.status}); this is the draft.`, true);
|
||
} else {
|
||
const piece = data.choices?.[0]?.delta?.content; // usage chunks have no choices
|
||
if (!piece) continue;
|
||
if (pieces++ === 0) first = performance.now();
|
||
if (st.mode === "draft") st.draft += piece; else st.final += piece;
|
||
lightMark(2);
|
||
}
|
||
show();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (error.name === "AbortError") stopped = true; else failure = error;
|
||
}
|
||
controller = null;
|
||
const content = st.mode === "draft" ? st.draft : st.final;
|
||
if (conv !== chat) { setStreaming(false); return; }
|
||
if (failure && !content) {
|
||
view.row.remove();
|
||
conv.messages.pop();
|
||
save();
|
||
const box = addBot();
|
||
box.note.hidden = false;
|
||
box.note.classList.add("bad");
|
||
box.note.textContent = `The model didn't answer (${failure.message}). Your message is back in the box: ` +
|
||
"send it again once the monitor shows the model as ready.";
|
||
draft.value = text;
|
||
autosize();
|
||
setStreaming(false);
|
||
return;
|
||
}
|
||
const seconds = (performance.now() - first) / 1000;
|
||
const parts = [];
|
||
if (first) parts.push(`${((first - start) / 1000).toFixed(1)} s to first token`);
|
||
if (pieces > 1) parts.push(`${((pieces - 1) / seconds).toFixed(1)} tokens/s`);
|
||
if (st.refined && st.draft) {
|
||
let same = 0;
|
||
while (same < st.draft.length && st.draft[same] === st.final[same]) same++;
|
||
parts.push(`refined, kept ${Math.round(100 * same / st.draft.length)}% of the draft`);
|
||
} else if (st.mode === "draft") parts.push("draft only");
|
||
if (stopped) parts.push("stopped");
|
||
if (failure) parts.push("cut off: " + failure.message);
|
||
const message = {role: "assistant", content, meta: parts.join(" · "), draft: st.mode === "draft"};
|
||
conv.messages.push(message);
|
||
save();
|
||
if (st.refined || !view.note.classList.contains("bad")) note("");
|
||
paint(view, content, "", false);
|
||
finish(view, message);
|
||
setStreaming(false);
|
||
}
|
||
|
||
$("composer").addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
if (streaming) controller && controller.abort(); else submit();
|
||
});
|
||
draft.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing && !coarse) {
|
||
event.preventDefault();
|
||
submit();
|
||
}
|
||
});
|
||
if (coarse) $("keys").textContent = "Return adds a line";
|
||
|
||
function autosize() {
|
||
draft.style.height = "auto";
|
||
draft.style.height = Math.min(draft.scrollHeight, innerHeight * 0.4) + "px";
|
||
if (!streaming) send.disabled = !draft.value.trim();
|
||
}
|
||
let warmTimer = 0;
|
||
function hideWarm() { clearTimeout(warmTimer); $("warm").hidden = true; $("keys").hidden = false; }
|
||
draft.addEventListener("input", () => {
|
||
autosize();
|
||
if (streaming || !draft.value.trim()) return;
|
||
fetch("/warm", {method: "POST", body: JSON.stringify({session: chat.session, messages: plain(), draft: draft.value})})
|
||
.then((response) => {
|
||
if (response.status !== 202) return;
|
||
$("warm").hidden = false;
|
||
$("keys").hidden = true;
|
||
clearTimeout(warmTimer);
|
||
warmTimer = setTimeout(hideWarm, 2500);
|
||
}).catch(() => {});
|
||
});
|
||
|
||
$("new-chat").addEventListener("click", () => {
|
||
if (controller) controller.abort();
|
||
chat = {session: newId(), messages: []};
|
||
save();
|
||
render();
|
||
draft.focus();
|
||
});
|
||
|
||
/* ---------- monitor ---------- */
|
||
const monitor = $("monitor"), scrim = $("scrim"), pill = $("pill");
|
||
function openMonitor(on) {
|
||
monitor.classList.toggle("open", on);
|
||
scrim.classList.toggle("open", on);
|
||
pill.setAttribute("aria-expanded", on);
|
||
}
|
||
pill.addEventListener("click", () => openMonitor(!monitor.classList.contains("open")));
|
||
$("close").addEventListener("click", () => openMonitor(false));
|
||
scrim.addEventListener("click", () => openMonitor(false));
|
||
addEventListener("keydown", (event) => { if (event.key === "Escape") openMonitor(false); });
|
||
|
||
const fmt = (n, digits = 0) => n.toLocaleString(undefined, {maximumFractionDigits: digits, minimumFractionDigits: digits});
|
||
const GIB = 2 ** 30;
|
||
const history = []; // polls of the last minute
|
||
const speeds = [];
|
||
let lastSpeed = 0, scanState = null;
|
||
|
||
function set(id, text) { $(id).textContent = text; }
|
||
function meter(id, share) { $(id).style.width = `${Math.max(0, Math.min(1, share)) * 100}%`; }
|
||
function setState(kind, text) {
|
||
for (const id of ["dot", "pill-dot"]) $(id).className = "dot " + kind;
|
||
set("state", text);
|
||
}
|
||
|
||
const SCAN = {off: [44, 37, 56], vram: [106, 53, 160], warm: [255, 107, 26], hot: [255, 212, 71], copy: [69, 211, 199]};
|
||
const mix = (a, b, t) => [0, 1, 2].map((k) => a[k] + (b[k] - a[k]) * t);
|
||
function drawScan(experts) {
|
||
const canvas = $("scan");
|
||
const layers = Object.keys(experts.layers).map(Number).sort((a, b) => a - b).map((k) => experts.layers[k]);
|
||
const rows = layers.length, cols = layers[0].uses.length, n = rows * cols;
|
||
if (!scanState || scanState.heat.length !== n) {
|
||
canvas.width = cols;
|
||
canvas.height = rows;
|
||
scanState = {heat: new Float32Array(n), flash: new Float32Array(n), uses: null, vram: null};
|
||
}
|
||
const uses = new Float64Array(n), vram = new Uint8Array(n);
|
||
layers.forEach((layer, l) => {
|
||
layer.uses.forEach((u, e) => (uses[l * cols + e] = u));
|
||
(layer.vram || []).forEach((e) => (vram[l * cols + e] = 1));
|
||
});
|
||
const {heat, flash} = scanState, ctx = canvas.getContext("2d"), image = ctx.createImageData(cols, rows);
|
||
let resident = 0, active = 0;
|
||
for (let k = 0; k < n; k++) {
|
||
const fired = scanState.uses ? uses[k] - scanState.uses[k] : 0;
|
||
heat[k] = Math.min(1, heat[k] * 0.5 + (fired > 0 ? 0.55 + 0.1 * Math.min(fired, 4) : 0));
|
||
flash[k] = scanState.vram && vram[k] && !scanState.vram[k] ? 1 : flash[k] * 0.4;
|
||
resident += vram[k];
|
||
active += fired > 0;
|
||
let c = vram[k] ? SCAN.vram : SCAN.off;
|
||
if (heat[k] > 0.02) c = mix(c, mix(SCAN.warm, SCAN.hot, heat[k]), Math.min(1, heat[k] * 1.5));
|
||
if (flash[k] > 0.02) c = mix(c, SCAN.copy, flash[k] * 0.85);
|
||
image.data.set([c[0], c[1], c[2], 255], k * 4);
|
||
}
|
||
ctx.putImageData(image, 0, 0);
|
||
scanState.uses = uses;
|
||
scanState.vram = vram;
|
||
set("scan-count", `${fmt(resident)} of ${fmt(n)} in VRAM`);
|
||
set("axis-end", `layer ${rows} · expert ${cols}`);
|
||
canvas.setAttribute("aria-label", `${resident} of ${n} experts in VRAM; ${active} used since the last update`);
|
||
set("empty-live", `${fmt(resident)} of ${fmt(n)} experts are in VRAM right now.`);
|
||
}
|
||
|
||
function totals(experts) {
|
||
const t = {hits: 0, misses: 0, cpu: 0};
|
||
for (const layer of Object.values(experts.layers)) for (const k in t) t[k] += layer[k] || 0;
|
||
return t;
|
||
}
|
||
|
||
async function poll() {
|
||
let s;
|
||
try {
|
||
const response = await fetch("/status", {cache: "no-store"});
|
||
s = await response.json();
|
||
} catch {
|
||
setState("bad", "Can't reach prain web");
|
||
set("pill-text", "Offline");
|
||
return;
|
||
}
|
||
set("model", s.model);
|
||
set("m-model", s.model);
|
||
const v = s.vllm, now = s.time;
|
||
const entry = {t: now, gen: v ? v.generation_tokens : null, ...(s.experts ? totals(s.experts) : {})};
|
||
history.push(entry);
|
||
while (history.length > 2 && now - history[0].t > 60) history.shift();
|
||
|
||
if (!v) {
|
||
setState("bad", "Model offline: starting or restarting");
|
||
set("pill-text", "Offline");
|
||
} else {
|
||
const busy = v.running > 0;
|
||
const prev = history[history.length - 2];
|
||
const dgen = prev && prev.gen != null ? entry.gen - prev.gen : 0; // < 0 after a restart
|
||
const rate = dgen > 0 ? dgen / Math.max(entry.t - prev.t, 0.1) : 0;
|
||
if (busy && rate) lastSpeed = lastSpeed ? lastSpeed * 0.4 + rate * 0.6 : rate;
|
||
speeds.push(busy ? rate : 0);
|
||
if (speeds.length > 60) speeds.shift();
|
||
$("speed-box").classList.toggle("idle", !busy || !rate);
|
||
set("speed", lastSpeed ? fmt(lastSpeed, 1) : "–");
|
||
set("speed-unit", busy && rate ? "tokens/s" : lastSpeed ? "tokens/s, last answer" : "tokens/s");
|
||
const top = Math.max(...speeds, 1);
|
||
$("spark").setAttribute("d", speeds.map((y, x) => `${x ? "L" : "M"}${x + 60 - speeds.length} ${27 - (y / top) * 25}`).join(""));
|
||
const waiting = v.waiting ? `, ${fmt(v.waiting)} waiting` : "";
|
||
if (busy) setState("live", `${rate ? "Generating" : "Reading the prompt"}${waiting}`);
|
||
else setState("ok", "Ready");
|
||
set("pill-text", !busy ? "Ready" : rate ? `${fmt(lastSpeed, 1)} tok/s` : "Working");
|
||
set("reqs", `${fmt(v.running)} running${waiting}`);
|
||
set("kv", `${fmt(v.kv_usage * 100)}%`);
|
||
meter("kv-bar", v.kv_usage);
|
||
set("prefix", v.prefix_queries ? `${fmt(100 * v.prefix_hits / v.prefix_queries)}%` : "–");
|
||
}
|
||
|
||
if (s.experts && Object.keys(s.experts.layers).length) {
|
||
$("scan-empty").hidden = true;
|
||
$("scan-body").hidden = false;
|
||
drawScan(s.experts);
|
||
const old = history.find((h) => h.hits !== undefined);
|
||
const d = old ? {hits: entry.hits - old.hits, misses: entry.misses - old.misses, cpu: entry.cpu - old.cpu} : null;
|
||
const routed = d && d.hits >= 0 && d.misses >= 0 ? d.hits + d.misses + d.cpu : 0; // < 0 after a restart
|
||
if (routed > 0) {
|
||
set("hits", `${fmt(100 * d.hits / routed)}%`);
|
||
set("copies", `${fmt(d.misses / Math.max(entry.t - old.t, 1), 0)}/s`);
|
||
} else {
|
||
const all = entry.hits + entry.misses + entry.cpu;
|
||
set("hits", all ? `${fmt(100 * entry.hits / all)}% overall` : "–");
|
||
set("copies", `${fmt(entry.misses)} so far`);
|
||
}
|
||
} else {
|
||
$("scan-empty").hidden = false;
|
||
$("scan-body").hidden = true;
|
||
set("empty-live", "");
|
||
}
|
||
|
||
const g = s.gpu;
|
||
if (g) {
|
||
set("vram", `${fmt(g.vram_used / GIB, 1)} / ${fmt(g.vram_total / GIB, 1)} GiB`);
|
||
meter("vram-bar", g.vram_used / g.vram_total);
|
||
set("load", g.busy == null ? "–" : `${g.busy}%`);
|
||
set("temp", g.temp == null ? "–" : `${fmt(g.temp)} °C`);
|
||
set("power", g.power == null ? "–" : `${fmt(g.power)} W`);
|
||
set("fan", g.fan == null ? "–" : `${fmt(g.fan)} rpm`);
|
||
}
|
||
}
|
||
|
||
let timer = 0;
|
||
function schedule() {
|
||
clearTimeout(timer);
|
||
if (document.visibilityState === "visible") timer = setTimeout(() => poll().finally(schedule), 1500);
|
||
}
|
||
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") poll().finally(schedule); });
|
||
|
||
render();
|
||
autosize();
|
||
poll().finally(schedule);
|
||
if (!coarse) draft.focus();
|
||
</script>
|