carbon-demo / index.html
lvwerra's picture
lvwerra HF Staff
Bump logprob sparkline target to 1000 points
fa23839 verified
raw
history blame
29.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carbon · DNA Continuation</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Inter:wght@300;400;500;600&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Inter", "Helvetica Neue", sans-serif;
font-size: 12px; font-weight: 300; line-height: 1.6;
color: #1a1a1a; background: #fafafa;
padding: 24px 32px;
max-width: 1280px; margin: 0 auto;
}
h1 {
font-family: "JetBrains Mono", monospace;
font-size: 16px; font-weight: 400; letter-spacing: 2px;
}
.meta {
font-family: "JetBrains Mono", monospace;
color: #888; font-size: 10px; font-weight: 300;
letter-spacing: 0.5px; margin-top: 4px;
}
.header-row {
margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid #ddd;
}
.section-title {
font-family: "JetBrains Mono", monospace;
font-size: 11px; font-weight: 400;
text-transform: uppercase; letter-spacing: 2px; color: #444;
margin-top: 24px; margin-bottom: 8px;
border-bottom: 1px solid #ccc; padding-bottom: 4px;
}
.seq-wrap { position: relative; }
.copy-btn {
position: absolute; top: 8px; right: 8px; z-index: 2;
font-family: "JetBrains Mono", monospace;
font-size: 9px; font-weight: 400;
padding: 3px 8px; border: 1px solid #ddd; border-radius: 3px;
background: #fff; color: #666; cursor: pointer;
text-transform: uppercase; letter-spacing: 1px;
transition: all 0.15s;
}
.copy-btn:hover { border-color: #888; color: #1a1a1a; }
.copy-btn:disabled { opacity: 0; pointer-events: none; }
.copy-btn.copied { background: #1a8a3a; color: #fff; border-color: #1a8a3a; }
/* --- Examples --- */
.examples {
display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px;
align-items: center;
}
.examples-label {
font-family: "JetBrains Mono", monospace;
font-size: 10px; color: #999; text-transform: uppercase; letter-spacing: 1px;
margin-right: 4px;
}
.ex-btn {
font-family: "JetBrains Mono", monospace;
font-size: 10px; padding: 3px 8px;
border: 1px solid #ddd; border-radius: 3px;
background: #fff; color: #666; cursor: pointer;
transition: all 0.15s;
}
.ex-btn:hover { border-color: #888; color: #1a1a1a; }
.ex-btn .ex-label {
color: #aaa; font-size: 9px; margin-left: 6px; text-transform: uppercase;
letter-spacing: 0.5px;
}
/* --- Inputs --- */
textarea, input[type=number] {
font-family: "JetBrains Mono", monospace;
font-size: 12px; font-weight: 300; color: #1a1a1a;
background: #fff; border: 1px solid #ddd; border-radius: 3px;
padding: 8px 12px; outline: none; transition: border 0.15s;
}
textarea:focus, input[type=number]:focus { border-color: #1a1a1a; }
textarea {
width: 100%; resize: none; overflow: hidden;
letter-spacing: 1px; line-height: 1.7;
min-height: 36px;
}
.controls {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
margin-top: 10px;
}
.control {
display: flex; align-items: center; gap: 6px;
font-family: "JetBrains Mono", monospace; font-size: 10px; color: #666;
text-transform: uppercase; letter-spacing: 1px;
}
.control input[type=number] {
width: 64px; padding: 4px 6px; font-size: 11px; text-align: right;
}
/* Color mode toggle */
.mode-group {
display: flex; align-items: center; gap: 6px;
font-family: "JetBrains Mono", monospace; font-size: 10px; color: #666;
text-transform: uppercase; letter-spacing: 1px;
}
.mode-btns { display: flex; }
.mode-btn {
font-family: "JetBrains Mono", monospace;
font-size: 10px; padding: 4px 9px;
border: 1px solid #ccc; border-right: none;
background: #fff; color: #666; cursor: pointer;
text-transform: uppercase; letter-spacing: 1px;
transition: all 0.15s;
}
.mode-btn:first-child { border-radius: 3px 0 0 3px; }
.mode-btn:last-child { border-right: 1px solid #ccc; border-radius: 0 3px 3px 0; }
.mode-btn:hover { color: #1a1a1a; }
.mode-btn.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
button.action {
font-family: "JetBrains Mono", monospace;
font-size: 10px; font-weight: 400;
padding: 5px 12px; border: 1px solid #ccc; border-radius: 3px;
background: #fff; color: #555; cursor: pointer;
text-transform: uppercase; letter-spacing: 1.5px;
transition: all 0.15s;
}
button.action:hover { border-color: #888; color: #1a1a1a; }
button.action.primary { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
button.action.primary:hover { background: #000; }
button.action:disabled { opacity: 0.4; cursor: not-allowed; }
button.action.primary:disabled { background: #888; border-color: #888; }
.button-row { margin-left: auto; display: flex; gap: 6px; }
/* --- Status --- */
.status {
font-family: "JetBrains Mono", monospace;
font-size: 10px; color: #666;
text-transform: uppercase; letter-spacing: 1.5px;
margin-top: 10px; min-height: 14px;
}
.status.error { color: #b00020; text-transform: none; letter-spacing: 0.3px; }
.status .dot {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: #888; margin-right: 6px; vertical-align: middle;
}
.status.streaming .dot { background: #1a8a3a; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.3; } }
/* --- Output layout: sequence + sticky stats sidebar --- */
.output-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 200px;
gap: 16px;
align-items: start;
}
.seq-block {
font-family: "JetBrains Mono", monospace;
background: #f4f4f4; border: 1px solid #ddd;
padding: 16px 20px; overflow-x: auto;
white-space: pre; font-size: 12px; font-weight: 400;
line-height: 1.85; letter-spacing: 1.5px;
min-height: 80px;
}
.seq-block.empty { color: #aaa; font-weight: 300; letter-spacing: normal; }
.seq-line { white-space: pre; }
.pos { color: #bbb; user-select: none; font-weight: 300; }
.seq-line.tail::after {
content: "";
display: inline-block; width: 7px; height: 14px;
background: #1a1a1a; vertical-align: text-bottom;
margin-left: 2px;
animation: blink 1s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* Token-block hover (for logprob/bases mode tooltips later) */
.tok { transition: background 0.1s; }
.tok:hover { background: #e8e8e8; }
/* --- Stats sidebar --- */
.stats {
position: sticky; top: 16px;
border: 1px solid #ddd; background: #fff;
}
.stat {
display: flex; justify-content: space-between; align-items: baseline;
padding: 8px 12px;
border-bottom: 1px solid #eee;
font-family: "JetBrains Mono", monospace;
}
.stat:last-child { border-bottom: none; }
.stat-label {
font-size: 9px; color: #999;
text-transform: uppercase; letter-spacing: 1.2px; font-weight: 300;
}
.stat-value {
font-size: 12px; font-weight: 400; color: #1a1a1a;
font-variant-numeric: tabular-nums;
}
.stat-value .unit { font-size: 9px; color: #999; margin-left: 3px; font-weight: 300; }
/* Logprob legend (only shown in logprob mode) */
.legend {
margin-top: 8px;
padding: 8px 12px;
background: #fff; border: 1px solid #ddd;
font-family: "JetBrains Mono", monospace;
font-size: 9px; color: #888;
text-transform: uppercase; letter-spacing: 1px;
display: none;
}
.legend.show { display: block; }
.legend-bar {
height: 6px; margin: 4px 0 3px;
background: linear-gradient(to right, #d83a2a, #888, #1a1a1a);
border-radius: 1px;
}
.legend-row { display: flex; justify-content: space-between; }
.lp-chart {
display: block; width: 100%; height: 40px;
margin-top: 8px;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
</style>
</head>
<body>
<div class="header-row">
<h1>CARBON · DNA CONTINUATION</h1>
<div class="meta" id="meta">loading…</div>
</div>
<div class="section-title">Prompt</div>
<div class="examples">
<span class="examples-label">examples</span>
<button class="ex-btn" data-ex="">empty<span class="ex-label">unconditional</span></button>
<button class="ex-btn" data-ex="ATG">ATG<span class="ex-label">start codon</span></button>
<button class="ex-btn" data-ex="TATAAA">TATAAA<span class="ex-label">TATA box</span></button>
<button class="ex-btn" data-ex="CGCGCGCGCG">CGCG…<span class="ex-label">CpG island</span></button>
<button class="ex-btn" data-ex="ATGGCCAAGCTGACCAGCGAGCTGCTG">ATGGCC…<span class="ex-label">ORF start</span></button>
<button class="ex-btn" data-ex="AAAAAAAAAAAAAAAA">A·16<span class="ex-label">poly-A</span></button>
</div>
<textarea id="prompt" rows="1" spellcheck="false" autocapitalize="characters">AGT</textarea>
<div class="controls">
<label class="control">max tokens
<input type="number" id="max_tokens" value="128" min="1" max="2048" step="1">
</label>
<label class="control">temperature
<input type="number" id="temperature" value="1.0" min="0" max="2" step="0.1">
</label>
<label class="control">top-p
<input type="number" id="top_p" value="1.0" min="0" max="1" step="0.05">
</label>
<div class="mode-group">color
<div class="mode-btns" id="mode-btns">
<button class="mode-btn active" data-mode="none">none</button>
<button class="mode-btn" data-mode="bases">bases</button>
<button class="mode-btn" data-mode="logprob">logprob</button>
</div>
</div>
<div class="button-row">
<button id="clear-btn" class="action">clear</button>
<button id="stop-btn" class="action" disabled>stop</button>
<button id="generate-btn" class="action primary">generate</button>
</div>
</div>
<div class="status" id="status"><span class="dot"></span><span id="status-text">idle</span></div>
<div class="section-title">Sequence</div>
<div class="output-row">
<div class="seq-wrap">
<button id="copy-btn" class="copy-btn" disabled>copy</button>
<div class="seq-block empty" id="seq">prompt + generated bases will stream here</div>
</div>
<div>
<div class="stats" id="stats">
<div class="stat"><span class="stat-label">prompt</span><span class="stat-value" id="stat-prompt">0<span class="unit">bp</span></span></div>
<div class="stat"><span class="stat-label">generated</span><span class="stat-value" id="stat-gen">0<span class="unit">bp</span></span></div>
<div class="stat"><span class="stat-label">tokens</span><span class="stat-value" id="stat-tok">0</span></div>
<div class="stat"><span class="stat-label">elapsed</span><span class="stat-value" id="stat-time">0.0<span class="unit">s</span></span></div>
<div class="stat"><span class="stat-label">throughput</span><span class="stat-value" id="stat-rate">0<span class="unit">bp/s</span></span></div>
<div class="stat"><span class="stat-label">GC content</span><span class="stat-value" id="stat-gc"></span></div>
<div class="stat"><span class="stat-label">mean logprob</span><span class="stat-value" id="stat-lp"></span></div>
<div class="stat"><span class="stat-label">perplexity</span><span class="stat-value" id="stat-ppl"></span></div>
</div>
<div class="legend" id="legend">
<div>token logprob</div>
<div class="legend-bar" id="legend-bar"></div>
<div class="legend-row"><span id="lp-min"></span><span id="lp-mid"></span><span id="lp-max"></span></div>
<svg id="lp-chart" class="lp-chart" preserveAspectRatio="none"></svg>
</div>
</div>
</div>
<script>
const els = {
prompt: document.getElementById("prompt"),
maxTokens: document.getElementById("max_tokens"),
temperature: document.getElementById("temperature"),
topP: document.getElementById("top_p"),
generate: document.getElementById("generate-btn"),
stop: document.getElementById("stop-btn"),
clear: document.getElementById("clear-btn"),
modeBtns: document.getElementById("mode-btns"),
copy: document.getElementById("copy-btn"),
seq: document.getElementById("seq"),
meta: document.getElementById("meta"),
status: document.getElementById("status"),
statusText: document.getElementById("status-text"),
legend: document.getElementById("legend"),
statPrompt: document.getElementById("stat-prompt"),
statGen: document.getElementById("stat-gen"),
statTok: document.getElementById("stat-tok"),
statTime: document.getElementById("stat-time"),
statRate: document.getElementById("stat-rate"),
statGc: document.getElementById("stat-gc"),
statLp: document.getElementById("stat-lp"),
statPpl: document.getElementById("stat-ppl"),
};
let promptBases = "";
let genText = "";
let genTokens = []; // [{text, logprob, top}]
let genTokenAtBase = []; // genTokenAtBase[i] = token index for generated base i
let abortCtrl = null;
let startTime = 0;
let timer = null;
let colorMode = "none";
let charMetrics = null; // {prefixW, blockW} pixel widths in seq-block font
// --- Color schemes (RGB tuples, used for both fg color and faint bg tint) ---
const BASE_RGB = {
A: [58, 138, 62],
C: [46, 107, 184],
G: [181, 137, 30],
T: [181, 58, 58],
N: [136, 136, 136],
};
const PROMPT_RGB = [170, 170, 170];
const DARK_RGB = [26, 26, 26];
const MID_RGB = [136, 136, 136];
const RED_RGB = [216, 58, 42];
const BG_ALPHA = 0.12;
// Logprob gradient: dynamically scaled to observed min/mean/max.
// Most confident (max) → DARK_RGB, mean → MID_RGB, most uncertain (min) → RED_RGB
let lpRange = null; // {min, mid, max} or null
function lerp(a, b, t) { return Math.round(a + (b - a) * t); }
function lerpRgb(c1, c2, t) {
return [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)];
}
function recomputeLpRange() {
if (!genTokens.length) { lpRange = null; updateLegend(); return; }
let min = Infinity, max = -Infinity, sum = 0, n = 0;
for (const t of genTokens) {
if (t.logprob == null || isNaN(t.logprob)) continue;
if (t.logprob < min) min = t.logprob;
if (t.logprob > max) max = t.logprob;
sum += t.logprob; n++;
}
lpRange = n ? { min, mid: sum / n, max } : null;
updateLegend();
}
function updateLegend() {
const minEl = document.getElementById("lp-min");
const midEl = document.getElementById("lp-mid");
const maxEl = document.getElementById("lp-max");
const bar = document.getElementById("legend-bar");
if (!lpRange) {
minEl.textContent = midEl.textContent = maxEl.textContent = "—";
bar.style.background = "linear-gradient(to right, #d83a2a, #888, #1a1a1a)";
} else {
const { min, mid, max } = lpRange;
minEl.textContent = min.toFixed(1);
midEl.textContent = mid.toFixed(1);
maxEl.textContent = max.toFixed(1);
const midPct = max > min ? ((mid - min) / (max - min)) * 100 : 50;
bar.style.background = `linear-gradient(to right, #d83a2a 0%, #888 ${midPct.toFixed(1)}%, #1a1a1a 100%)`;
}
updateLpChart();
}
function updateLpChart() {
const svg = document.getElementById("lp-chart");
if (!svg) return;
if (!lpRange || genTokens.length < 2) {
svg.innerHTML = "";
return;
}
const W = 200, H = 40, pad = 2;
const { min, mid, max } = lpRange;
const yTop = pad, yBot = H - pad;
const yScale = (lp) => yTop + (1 - (lp - min) / Math.max(1e-9, max - min)) * (yBot - yTop);
// Subsample with power-of-two step so the visible point set is stable
// between transitions (transitions at n = target, 2*target, 4*target, ...).
const n = genTokens.length;
const target = 1000;
let step = 1;
while (Math.ceil(n / step) > target) step *= 2;
const xScale = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));
let d = "";
let started = false;
for (let i = 0; i < n; i += step) {
const lp = genTokens[i].logprob;
if (lp == null || isNaN(lp)) continue;
d += (started ? "L" : "M") + xScale(i).toFixed(1) + " " + yScale(lp).toFixed(1);
started = true;
}
// Always anchor the very last token, even if subsampling skipped it.
if ((n - 1) % step !== 0) {
const lp = genTokens[n - 1].logprob;
if (lp != null && !isNaN(lp)) {
d += "L" + xScale(n - 1).toFixed(1) + " " + yScale(lp).toFixed(1);
}
}
const midPct = max > min ? ((mid - min) / (max - min)) * 100 : 50;
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.innerHTML = `
<defs>
<linearGradient id="lp-grad" gradientUnits="userSpaceOnUse" x1="0" y1="${yBot}" x2="0" y2="${yTop}">
<stop offset="0%" stop-color="#d83a2a"/>
<stop offset="${midPct.toFixed(1)}%" stop-color="#888"/>
<stop offset="100%" stop-color="#1a1a1a"/>
</linearGradient>
</defs>
<path d="${d}" fill="none" stroke="url(#lp-grad)" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
`;
}
function logprobRgb(lp) {
if (lp == null || isNaN(lp) || !lpRange) return DARK_RGB;
const { min, mid, max } = lpRange;
if (max === min) return MID_RGB;
if (lp >= mid) {
const denom = max - mid;
const t = denom > 0 ? Math.min(1, Math.max(0, (max - lp) / denom)) : 0;
return lerpRgb(DARK_RGB, MID_RGB, t);
}
const denom = mid - min;
const t = denom > 0 ? Math.min(1, Math.max(0, (mid - lp) / denom)) : 0;
return lerpRgb(MID_RGB, RED_RGB, t);
}
// --- Init ---
async function init() {
try {
const resp = await fetch("/config");
const cfg = await resp.json();
els.meta.textContent = `${cfg.model} · ${cfg.endpoint}`;
} catch {
els.meta.textContent = "config unavailable";
}
}
// --- Auto-grow textarea ---
function autoGrow() {
els.prompt.style.height = "auto";
els.prompt.style.height = els.prompt.scrollHeight + "px";
}
els.prompt.addEventListener("input", () => {
const cleaned = cleanPrompt(els.prompt.value);
if (cleaned !== els.prompt.value) {
const pos = els.prompt.selectionStart;
els.prompt.value = cleaned;
els.prompt.setSelectionRange(pos, pos);
}
autoGrow();
});
window.addEventListener("load", autoGrow);
// --- Prompt validation ---
function cleanPrompt(s) {
return s.toUpperCase().replace(/[^ACGTN]/g, "");
}
// --- Sequence rendering ---
function rgbForBase(absIdx, base) {
// absIdx: 0-indexed position in prompt+gen string
if (absIdx < promptBases.length) return PROMPT_RGB;
if (colorMode === "bases") return BASE_RGB[base] || DARK_RGB;
if (colorMode === "logprob") {
const genIdx = absIdx - promptBases.length;
const tok = genTokens[genTokenAtBase[genIdx]];
return tok ? logprobRgb(tok.logprob) : DARK_RGB;
}
return DARK_RGB;
}
function measureSeqChars() {
const probe = document.createElement("div");
probe.style.cssText = "position:absolute;visibility:hidden;top:-9999px;font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:400;letter-spacing:1.5px;white-space:pre";
probe.textContent = " 1 ";
document.body.appendChild(probe);
const prefixW = probe.getBoundingClientRect().width;
probe.textContent = "AAAAAAAAAA ";
const blockW = probe.getBoundingClientRect().width;
document.body.removeChild(probe);
charMetrics = { prefixW, blockW };
}
function basesPerLine() {
if (!charMetrics) measureSeqChars();
const cs = getComputedStyle(els.seq);
const padL = parseFloat(cs.paddingLeft) || 0;
const padR = parseFloat(cs.paddingRight) || 0;
const contentW = els.seq.clientWidth - padL - padR;
if (contentW <= 0 || !charMetrics.blockW) return 60;
const blocks = Math.floor((contentW - charMetrics.prefixW) / charMetrics.blockW);
return Math.max(10, Math.min(blocks, 30) * 10);
}
// Cheap discriminator: bases sharing a key inside the same 10-block fold into one span.
function colorKey(absIdx, base) {
if (absIdx < promptBases.length) return "p";
if (colorMode === "none") return "g";
if (colorMode === "bases") return "b" + base;
if (colorMode === "logprob") return "t" + genTokenAtBase[absIdx - promptBases.length];
return "g";
}
function buildLineHTML(start, lineBases) {
const pos = String(start + 1).padStart(5, " ");
let html = `<span class="pos">${pos}</span> `;
let j = 0;
while (j < lineBases.length) {
if (j > 0 && j % 10 === 0) html += " ";
const startAbs = start + j;
const startKey = colorKey(startAbs, lineBases[j]);
const blockEnd = Math.min(lineBases.length, Math.floor(j / 10) * 10 + 10);
let runEnd = j + 1;
while (runEnd < blockEnd && colorKey(start + runEnd, lineBases[runEnd]) === startKey) runEnd++;
const runText = lineBases.slice(j, runEnd);
const [r, g, b] = rgbForBase(startAbs, lineBases[j]);
const tinted = colorMode !== "none" && startAbs >= promptBases.length;
const bg = tinted ? `;background:rgba(${r},${g},${b},${BG_ALPHA})` : "";
html += `<span style="color:rgb(${r},${g},${b})${bg}">${runText}</span>`;
j = runEnd;
}
return html;
}
function updateTail() {
const prev = els.seq.querySelector(".seq-line.tail");
if (prev) prev.classList.remove("tail");
const last = els.seq.lastElementChild;
if (abortCtrl && last && last.classList.contains("seq-line")) last.classList.add("tail");
}
// Re-render frequency for logprob: tolerance is max of 0.2 absolute and 5% of current range.
function lpRangeShifted(prev, curr) {
if (!prev || !curr) return prev !== curr;
const range = Math.max(0.1, prev.max - prev.min);
const tol = Math.max(0.2, range * 0.05);
return Math.abs(prev.min - curr.min) > tol
|| Math.abs(prev.mid - curr.mid) > tol
|| Math.abs(prev.max - curr.max) > tol;
}
let lastRenderedMode = null;
let lastRenderedBpl = null;
let lastRenderedLpRange = null;
function fullRender(bpl) {
const total = promptBases + genText;
if (!total) {
els.seq.classList.add("empty");
els.seq.textContent = "prompt + generated bases will stream here";
} else {
els.seq.classList.remove("empty");
const parts = [];
for (let i = 0; i < total.length; i += bpl) {
parts.push(`<div class="seq-line">${buildLineHTML(i, total.slice(i, i + bpl))}</div>`);
}
els.seq.innerHTML = parts.join("");
}
lastRenderedMode = colorMode;
lastRenderedBpl = bpl;
lastRenderedLpRange = lpRange ? { ...lpRange } : null;
updateTail();
}
function incrementalRender(bpl) {
const total = promptBases + genText;
const totalLines = Math.ceil(total.length / bpl);
const lineDivs = els.seq.children;
if (lineDivs.length > 0) {
const lastIdx = lineDivs.length - 1;
const start = lastIdx * bpl;
lineDivs[lastIdx].innerHTML = buildLineHTML(start, total.slice(start, start + bpl));
}
if (totalLines > lineDivs.length) {
const parts = [];
for (let li = lineDivs.length; li < totalLines; li++) {
const start = li * bpl;
parts.push(`<div class="seq-line">${buildLineHTML(start, total.slice(start, start + bpl))}</div>`);
}
els.seq.insertAdjacentHTML("beforeend", parts.join(""));
}
lastRenderedLpRange = lpRange ? { ...lpRange } : null;
updateTail();
}
function renderSequence() {
if (colorMode === "logprob") recomputeLpRange();
const total = promptBases + genText;
els.copy.disabled = total.length === 0;
const bpl = basesPerLine();
const totalLines = total ? Math.ceil(total.length / bpl) : 0;
const renderedLines = els.seq.children.length;
const needFull =
!total ||
lastRenderedMode !== colorMode ||
lastRenderedBpl !== bpl ||
totalLines < renderedLines ||
(colorMode === "logprob" && lpRangeShifted(lastRenderedLpRange, lpRange));
if (needFull) fullRender(bpl);
else incrementalRender(bpl);
}
let renderQueued = false;
function scheduleRender() {
if (renderQueued) return;
renderQueued = true;
requestAnimationFrame(() => { renderQueued = false; renderSequence(); });
}
// --- Stats ---
function gcContent(s) {
if (!s) return null;
let gc = 0;
for (const c of s) if (c === "G" || c === "C") gc++;
return (gc / s.length) * 100;
}
function meanLogprob() {
if (!genTokens.length) return null;
let sum = 0, n = 0;
for (const t of genTokens) {
if (t.logprob != null && !isNaN(t.logprob)) { sum += t.logprob; n++; }
}
return n ? sum / n : null;
}
function updateStats() {
els.statPrompt.innerHTML = `${promptBases.length}<span class="unit">bp</span>`;
els.statGen.innerHTML = `${genText.length}<span class="unit">bp</span>`;
els.statTok.textContent = genTokens.length;
const elapsed = startTime ? (performance.now() - startTime) / 1000 : 0;
els.statTime.innerHTML = `${elapsed.toFixed(1)}<span class="unit">s</span>`;
const rate = elapsed > 0 ? Math.round(genText.length / elapsed) : 0;
els.statRate.innerHTML = `${rate}<span class="unit">bp/s</span>`;
const gc = gcContent(genText);
els.statGc.textContent = gc == null ? "—" : `${gc.toFixed(1)}%`;
const mlp = meanLogprob();
els.statLp.textContent = mlp == null ? "—" : mlp.toFixed(2);
els.statPpl.textContent = mlp == null ? "—" : Math.exp(-mlp).toFixed(1);
}
function setStatus(text, mode = "") {
els.statusText.textContent = text;
els.status.className = "status" + (mode ? " " + mode : "");
}
// --- Color mode toggle ---
els.modeBtns.querySelectorAll(".mode-btn").forEach(b => {
b.addEventListener("click", () => {
colorMode = b.dataset.mode;
els.modeBtns.querySelectorAll(".mode-btn").forEach(x => x.classList.toggle("active", x === b));
els.legend.classList.toggle("show", colorMode === "logprob");
renderSequence();
});
});
// --- Generation ---
async function generate() {
if (abortCtrl) return;
promptBases = cleanPrompt(els.prompt.value);
genText = "";
genTokens = [];
genTokenAtBase = [];
startTime = performance.now();
abortCtrl = new AbortController();
els.generate.disabled = true;
els.stop.disabled = false;
setStatus("connecting…", "streaming");
renderSequence();
updateStats();
timer = setInterval(updateStats, 100);
const body = {
prompt: promptBases,
max_tokens: parseInt(els.maxTokens.value),
temperature: parseFloat(els.temperature.value),
top_p: parseFloat(els.topP.value),
};
try {
const resp = await fetch("/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: abortCtrl.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
}
setStatus("streaming", "streaming");
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop();
for (const ev of events) {
const line = ev.trim();
if (!line.startsWith("data:")) continue;
const data = JSON.parse(line.slice(5).trim());
if (data.error) throw new Error(data.error);
if (data.done) continue;
if (data.logprobs) {
const lp = data.logprobs;
for (let i = 0; i < lp.tokens.length; i++) {
const tokIdx = genTokens.length;
genTokens.push({
text: lp.tokens[i],
logprob: lp.token_logprobs[i],
top: lp.top_logprobs[i],
});
for (let j = 0; j < lp.tokens[i].length; j++) genTokenAtBase.push(tokIdx);
}
}
if (data.text) {
genText += cleanPrompt(data.text);
scheduleRender();
}
}
}
setStatus("done");
} catch (e) {
if (e.name === "AbortError") setStatus("stopped");
else setStatus(e.message, "error");
} finally {
abortCtrl = null;
clearInterval(timer);
updateStats();
renderSequence();
els.generate.disabled = false;
els.stop.disabled = true;
}
}
function stop() { if (abortCtrl) abortCtrl.abort(); }
function clearAll() {
if (abortCtrl) return;
promptBases = "";
genText = "";
genTokens = [];
genTokenAtBase = [];
startTime = 0;
renderSequence();
updateStats();
setStatus("idle");
}
els.generate.addEventListener("click", generate);
els.stop.addEventListener("click", stop);
els.clear.addEventListener("click", clearAll);
els.copy.addEventListener("click", async () => {
const text = promptBases + genText;
if (!text) return;
try {
await navigator.clipboard.writeText(text);
els.copy.classList.add("copied");
els.copy.textContent = "copied";
} catch {
els.copy.textContent = "failed";
}
setTimeout(() => {
els.copy.classList.remove("copied");
els.copy.textContent = "copy";
}, 1200);
});
els.prompt.addEventListener("keydown", e => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
generate();
}
});
document.querySelectorAll(".ex-btn").forEach(btn => {
btn.addEventListener("click", () => {
els.prompt.value = btn.dataset.ex;
autoGrow();
els.prompt.focus();
});
});
init();
updateStats();
// Re-render on width change (debounced via rAF) and after web font loads
let roPending = false;
const ro = new ResizeObserver(() => {
if (roPending) return;
roPending = true;
requestAnimationFrame(() => { roPending = false; renderSequence(); });
});
ro.observe(els.seq);
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => { charMetrics = null; renderSequence(); });
}
</script>
</body>
</html>