carbon-helix-2d / index3.html
ysharma's picture
ysharma HF Staff
Rename index.html to index3.html
ec27cab verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Carbon Helix Β· 2D</title>
<style>
/* ════════════════════════════════════════════════════════════════
Palette β€” scientific light mode. Warm parchment background, deep
charcoal text, one warm amber accent, vivid base colors. The
bases follow the standard bio convention (A green, T red, G ink,
C blue) at high enough saturation to pop on the cream bg without
glowing. No gradients, no neon β€” the look should read as a
textbook figure, not a Stable-Diffusion product mockup.
════════════════════════════════════════════════════════════════ */
:root {
--bg-0: #f6f2e8; /* warm parchment */
--bg-1: #efe9d8; /* sidebar β€” slightly darker */
--bg-card: #fbfaf4; /* viz card β€” lighter, pulls focus */
--fg: #131922; /* deep charcoal */
--fg-mid: #4b5563; /* secondary text */
--fg-dim: #8b8478; /* warm gray */
--accent: #b46a2a; /* warm amber */
--accent-2: #7a4d1e; /* deeper amber for hover */
--border: rgba(19, 25, 34, 0.12);
--border-2: rgba(19, 25, 34, 0.22);
/* Bases */
--A: #168547; /* forest green */
--T: #c0302a; /* brick red */
--G: #1f2937; /* ink charcoal */
--C: #1f4f9c; /* deep blue */
--user-base: #a39d8b; /* warm muted gray */
/* Helix backbone + rungs */
--strand: #6b7280;
--strand-2: #4b5563; /* contrast */
--rung: #cdd0d6;
--rung-user: #d8d4c4;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
--serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: var(--bg-0);
color: var(--fg);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
#app {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
}
/* ── Header ─────────────────────────────────────────────── */
#header {
display: flex; align-items: baseline; gap: 18px;
padding: 18px 26px 14px;
background: var(--bg-0);
border-bottom: 1px solid var(--border);
}
.title {
font-family: var(--serif);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.01em;
color: var(--fg);
}
.subtitle {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
padding: 2px 8px;
border: 1px solid var(--accent);
border-radius: 3px;
}
.model-info {
color: var(--fg-mid);
font-size: 12px;
font-family: var(--mono);
padding-left: 18px; margin-left: 6px;
border-left: 1px solid var(--border);
}
.legend {
margin-left: auto;
display: flex; gap: 16px;
font-family: var(--mono);
font-size: 12px;
color: var(--fg-mid);
}
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
.legend-item .swatch {
width: 9px; height: 9px; border-radius: 50%;
}
.legend-item.A .swatch { background: var(--A); }
.legend-item.T .swatch { background: var(--T); }
.legend-item.G .swatch { background: var(--G); }
.legend-item.C .swatch { background: var(--C); }
/* ── Main ───────────────────────────────────────────────── */
#main {
display: grid;
grid-template-columns: 300px 1fr;
overflow: hidden;
}
#sidebar {
background: var(--bg-1);
border-right: 1px solid var(--border);
padding: 22px 22px 28px;
overflow-y: auto;
}
#viz {
display: flex; flex-direction: column;
padding: 22px 32px;
overflow: hidden;
background: var(--bg-0);
}
/* ── Sidebar controls ─────────────────────────────────── */
.row { margin-bottom: 20px; }
.row > label {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mid); font-weight: 700;
margin-bottom: 7px;
}
.row > label .val {
color: var(--accent); font-variant-numeric: tabular-nums;
font-family: var(--mono); font-size: 12px;
letter-spacing: 0;
}
textarea, input[type="text"] {
width: 100%; resize: vertical; min-height: 64px;
background: var(--bg-card);
border: 1px solid var(--border-2);
border-radius: 4px;
padding: 10px 12px;
color: var(--fg);
font-family: var(--mono); font-size: 13px;
outline: none;
letter-spacing: 0.04em;
transition: border-color 0.15s, box-shadow 0.15s;
}
textarea:focus, input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(180, 106, 42, 0.12);
}
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 3px;
background: var(--accent);
border-radius: 2px; outline: none;
margin: 8px 0;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: var(--bg-card);
border: 2px solid var(--accent);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%;
background: var(--bg-card);
border: 2px solid var(--accent);
cursor: pointer;
}
.chips { display: flex; flex-wrap: wrap; gap: 5px; }
.chip {
padding: 5px 11px;
border-radius: 3px;
background: transparent;
border: 1px solid var(--border-2);
color: var(--fg-mid);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
user-select: none;
font-family: var(--mono);
letter-spacing: 0.02em;
}
.chip:hover { color: var(--fg); border-color: var(--fg-mid); }
.chip.active {
background: var(--accent);
color: var(--bg-card);
border-color: var(--accent);
}
.preset-row { display: flex; gap: 5px; margin-top: 8px; }
.preset-row .chip { flex: 1; text-align: center; }
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
.btn {
flex: 1; padding: 11px 14px;
border-radius: 4px; border: 1px solid var(--border-2);
font-family: var(--sans); font-weight: 700; font-size: 11px;
letter-spacing: 0.10em; text-transform: uppercase;
cursor: pointer; transition: all 0.15s;
}
.btn-primary {
background: var(--accent);
color: var(--bg-card);
border-color: var(--accent);
}
.btn-primary:hover { background: var(--accent-2); border-color: var(--accent-2); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost {
background: transparent;
color: var(--fg);
}
.btn-ghost:hover { background: rgba(19, 25, 34, 0.05); border-color: var(--fg-mid); }
.hint {
font-size: 11px; color: var(--fg-dim);
margin-top: 8px;
line-height: 1.5;
font-family: var(--sans);
}
.hint em { font-style: italic; color: var(--fg-mid); }
/* ── Viz / helix area ──────────────────────────────────── */
#viz-title {
font-family: var(--mono);
font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase;
color: var(--fg-mid);
margin-bottom: 12px;
display: flex; gap: 16px; align-items: baseline;
}
#viz-title .num { color: var(--fg); font-weight: 700; font-size: 13px; }
#viz-title .user-num { color: var(--fg-dim); }
#viz-title .gen-num { color: var(--accent); }
#dna-scroll {
flex: 1;
min-height: 0;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 20px 24px;
scrollbar-width: thin;
scrollbar-color: var(--accent) transparent;
}
#dna-scroll::-webkit-scrollbar { width: 8px; }
#dna-scroll::-webkit-scrollbar-track { background: transparent; }
#dna-scroll::-webkit-scrollbar-thumb {
background: rgba(180, 106, 42, 0.4);
border-radius: 4px;
}
#dna-empty {
color: var(--fg-dim);
font-family: var(--sans);
font-style: italic;
font-size: 13px;
text-align: center;
padding: 60px 0;
}
#dna-empty strong { font-style: normal; color: var(--accent); }
#dna {
display: flex; flex-direction: column;
gap: 4px;
}
/* ── A helix row: position label + SVG helix ──────────── */
.row-block {
display: flex; align-items: stretch;
gap: 14px;
}
.row-pos {
flex-shrink: 0;
width: 38px;
padding-top: 4px;
font-family: var(--mono);
font-size: 11px;
color: var(--fg-dim);
text-align: right;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
border-right: 1px solid var(--border);
padding-right: 12px;
}
.helix-svg {
display: block;
overflow: visible;
}
/* ── SVG element styling ───────────────────────────────── */
.strand {
fill: none;
stroke: var(--strand);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.strand.back {
stroke: var(--strand);
stroke-width: 1;
opacity: 0.45;
}
.rung {
stroke: var(--rung);
stroke-width: 1;
stroke-linecap: round;
}
.rung.user {
stroke: var(--rung-user);
stroke-dasharray: 1.5 2;
}
.base {
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
text-anchor: middle;
dominant-baseline: central;
user-select: none;
}
.base.A { fill: var(--A); }
.base.T { fill: var(--T); }
.base.G { fill: var(--G); }
.base.C { fill: var(--C); }
.base.user { fill: var(--user-base); font-weight: 500; }
/* Pop-in animation for newly streamed bases. SMIL is heavier than
CSS transforms; instead, we set an `appearing` class on the
newest base and clear it after one tick. */
.base.gen.appearing {
animation: pop 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: center;
transform-box: fill-box;
}
@keyframes pop {
0% { transform: scale(0); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
/* User β†’ gen boundary: a thin vertical accent line across the row
and a tiny "β€’" mark on the top edge. */
.boundary {
stroke: var(--accent);
stroke-width: 1.5;
stroke-dasharray: 3 2;
}
.boundary-cap {
fill: var(--accent);
}
/* ── HUD ────────────────────────────────────────────────── */
#hud {
display: flex; gap: 32px;
padding: 12px 26px;
background: var(--bg-0);
border-top: 1px solid var(--border);
font-family: var(--mono);
font-size: 12px;
}
.hud-item { display: flex; gap: 8px; align-items: baseline; }
.hud-item .k {
color: var(--fg-mid);
letter-spacing: 0.10em; text-transform: uppercase;
font-size: 10px;
}
.hud-item .v {
color: var(--fg); font-weight: 700;
font-variant-numeric: tabular-nums;
font-size: 13px;
}
.v.streaming { color: var(--accent); }
.v.error { color: var(--T); }
.v.done { color: var(--A); }
#errbar {
position: fixed;
bottom: 60px; left: 50%; transform: translateX(-50%);
background: var(--bg-card);
border: 1px solid var(--T);
color: var(--T);
padding: 9px 18px;
border-radius: 4px;
font-family: var(--mono); font-size: 12px;
z-index: 100;
box-shadow: 0 4px 16px rgba(192, 48, 42, 0.15);
}
@media (max-width: 900px) {
#main { grid-template-columns: 1fr; }
#sidebar { border-right: none; border-bottom: 1px solid var(--border); }
}
</style>
</head>
<body>
<div id="app">
<header id="header">
<span class="title">Carbon Helix</span>
<span class="subtitle">2D</span>
<span class="model-info" id="model-info">connecting…</span>
<div class="legend">
<span class="legend-item A"><span class="swatch"></span>A</span>
<span class="legend-item T"><span class="swatch"></span>T</span>
<span class="legend-item G"><span class="swatch"></span>G</span>
<span class="legend-item C"><span class="swatch"></span>C</span>
</div>
</header>
<div id="main">
<aside id="sidebar">
<div class="row">
<label>Seed DNA <span class="val" id="seed-len">0 bp</span></label>
<textarea id="seed" spellcheck="false" autocomplete="off">ATGGCCATGGCC</textarea>
<div class="hint">Type ACGT bases. They'll appear <em>in gray</em> as you type β€” the model picks up from there.</div>
<div class="preset-row">
<span class="chip" data-preset="atg">ATG-start</span>
<span class="chip" data-preset="tata">TATA box</span>
<span class="chip" data-preset="random">Random</span>
</div>
</div>
<div class="row">
<label>Metadata tags</label>
<div class="chips" id="meta-chips">
<span class="chip" data-tag="vertebrate_mammalian">vertebrate_mammalian</span>
<span class="chip" data-tag="protein_coding_region">protein_coding_region</span>
<span class="chip" data-tag="invertebrate">invertebrate</span>
<span class="chip" data-tag="plant">plant</span>
</div>
</div>
<div class="row">
<label>Max tokens <span class="val" id="mt-val">128</span></label>
<input type="range" id="mt" min="1" max="200" value="128" />
</div>
<div class="row">
<label>Temperature <span class="val" id="t-val">0.70</span></label>
<input type="range" id="t" min="0" max="200" value="70" />
</div>
<div class="row">
<label>Top-p <span class="val" id="tp-val">0.90</span></label>
<input type="range" id="tp" min="0" max="100" value="90" />
</div>
<div class="btn-row">
<button id="generate" class="btn btn-primary">β–Ά Generate</button>
<button id="reset" class="btn btn-ghost">Reset</button>
</div>
</aside>
<div id="viz">
<div id="viz-title">
<span><span class="num user-num" id="user-count">0</span> user bp</span>
<span><span class="num gen-num" id="gen-count">0</span> generated bp</span>
<span><span class="num" id="total-bp">0</span> total</span>
</div>
<div id="dna-scroll">
<div id="dna-empty" style="display:none">type a seed in the sidebar to start, then click <strong>Generate</strong></div>
<div id="dna"></div>
</div>
</div>
</div>
<footer id="hud">
<div class="hud-item"><span class="k">Status</span><span class="v" id="status">idle</span></div>
<div class="hud-item"><span class="k">Tokens</span><span class="v" id="tok">0</span></div>
<div class="hud-item"><span class="k">Rate</span><span class="v" id="rate">β€”</span><span class="k">tok/s</span></div>
<div class="hud-item"><span class="k">Mean log-p</span><span class="v" id="meanlp">β€”</span></div>
</footer>
</div>
<script type="module">
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.16.0/dist/index.min.js";
// ════════════════════════════════════════════════════════════════════
// HELIX GEOMETRY
// ──────────────────────────────────────────────────────────────────
// The two strands trace sinusoids 180Β° out of phase, so they cross
// at every half-turn β€” that's the iconic "X" pattern of double-
// helix side projections. Base letters sit ON the strand at each
// position; the rung is a vertical line connecting the two letters.
// At a crossing, both letters are at Y_CENTER and the rung has
// zero length (visually clean β€” base pair is edge-on).
// ════════════════════════════════════════════════════════════════════
const BP_PER_TURN = 10; // real DNA is ~10.5; round number reads cleanly
const BASE_SPACING = 26; // px between consecutive bases
const AMPLITUDE = 30; // px sine amplitude
const ROW_HEIGHT = 120;
const Y_CENTER = ROW_HEIGHT / 2;
const SAMPLES_PER_BASE = 8; // smoothness of the strand curves
const SVG_NS = "http://www.w3.org/2000/svg";
// ════════════════════════════════════════════════════════════════════
// DOM REFS
// ════════════════════════════════════════════════════════════════════
const seed = document.getElementById("seed");
const seedLen = document.getElementById("seed-len");
const mtIn = document.getElementById("mt");
const mtVal = document.getElementById("mt-val");
const tIn = document.getElementById("t");
const tVal = document.getElementById("t-val");
const tpIn = document.getElementById("tp");
const tpVal = document.getElementById("tp-val");
const genBtn = document.getElementById("generate");
const resetBtn = document.getElementById("reset");
const dnaEl = document.getElementById("dna");
const dnaScroll = document.getElementById("dna-scroll");
const dnaEmpty = document.getElementById("dna-empty");
const modelInfoEl = document.getElementById("model-info");
const userCountEl = document.getElementById("user-count");
const genCountEl = document.getElementById("gen-count");
const totalBpEl = document.getElementById("total-bp");
const hud = {
status: document.getElementById("status"),
tok: document.getElementById("tok"),
rate: document.getElementById("rate"),
meanlp: document.getElementById("meanlp"),
};
// ════════════════════════════════════════════════════════════════════
// STATE
// ════════════════════════════════════════════════════════════════════
const COMP = { A: "T", T: "A", G: "C", C: "G", N: "N" };
const state = {
user: [], // [{ base }]
gen: [], // [{ base, lp }]
};
let client = null;
let activeJob = null;
let runStats = { tokens: 0, lpSum: 0, lpCount: 0, t0: 0 };
// ════════════════════════════════════════════════════════════════════
// HELPERS
// ════════════════════════════════════════════════════════════════════
function cleanDna(s) {
return (s || "").toUpperCase().replace(/[^ACGT]/g, "");
}
function showError(msg) {
let bar = document.getElementById("errbar");
if (!bar) {
bar = document.createElement("div");
bar.id = "errbar";
document.body.appendChild(bar);
}
bar.textContent = msg;
bar.style.display = "block";
clearTimeout(showError._t);
showError._t = setTimeout(() => bar.remove(), 5500);
}
function getMetadata() {
return [...document.querySelectorAll("#meta-chips .chip.active")]
.map(c => `<${c.dataset.tag}>`).join("");
}
// Compute how many bases fit on one helix row, given the current
// viewport. Snap to multiples of 10 so position labels read cleanly
// (1, 41, 81, …) and reflow on resize.
function computeBasesPerRow() {
const avail = dnaScroll.clientWidth
- 38 /* .row-pos width */
- 14 /* gap */
- 12 /* row-pos right padding */
- 50; /* container padding slack */
const fit = Math.floor(avail / BASE_SPACING);
const snapped = Math.floor(fit / 10) * 10;
return Math.max(20, Math.min(80, snapped));
}
// ════════════════════════════════════════════════════════════════════
// SVG-BASED HELIX RENDERING
// ════════════════════════════════════════════════════════════════════
function svg(tag, attrs = {}) {
const el = document.createElementNS(SVG_NS, tag);
for (const k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
// Given the index of a base (0 = first base on this row), return its
// (x, top_y, bot_y) coordinates. The +0.5 in the angle centers the
// base in its slot β€” bases sit *between* the conceptual grid lines.
function geom(i) {
const x = i * BASE_SPACING + BASE_SPACING / 2;
const angle = (i + 0.5) * 2 * Math.PI / BP_PER_TURN;
const yTop = Y_CENTER + AMPLITUDE * Math.sin(angle);
const yBot = Y_CENTER - AMPLITUDE * Math.sin(angle);
return { x, yTop, yBot };
}
// Build a smooth polyline path for the strand backbone β€” NΓ—SAMPLES
// straight segments between bases is fine at our scale.
function strandPath(n, sign) {
const pts = [];
for (let s = 0; s <= n * SAMPLES_PER_BASE; s++) {
const t = s / SAMPLES_PER_BASE; // continuous base index
const x = t * BASE_SPACING + BASE_SPACING / 2;
const angle = (t + 0.5) * 2 * Math.PI / BP_PER_TURN;
const y = Y_CENTER + sign * AMPLITUDE * Math.sin(angle);
pts.push(`${x.toFixed(2)},${y.toFixed(2)}`);
}
return pts.join(" ");
}
// Build one row of the helix as an SVG. `rowBases` is the slice of
// bases on this row, `userLen` is the global user-prefix length so
// we can render the boundary marker if it falls inside this row.
function buildRowSVG(rowStart, rowBases, userLen) {
const n = rowBases.length;
const width = n * BASE_SPACING + 6;
const root = svg("svg", {
class: "helix-svg",
width, height: ROW_HEIGHT,
viewBox: `0 0 ${width} ${ROW_HEIGHT}`,
});
// Strand backbones. Two polylines, one strand drawn slightly
// thicker than the other so the eye can follow the "front" strand
// (the .back one fades into the background near the crossings).
root.appendChild(svg("polyline", {
class: "strand",
points: strandPath(n, +1),
}));
root.appendChild(svg("polyline", {
class: "strand back",
points: strandPath(n, -1),
}));
// Boundary marker (vertical accent line + cap)
const boundaryGlobal = userLen;
if (boundaryGlobal > rowStart && boundaryGlobal < rowStart + n) {
const bi = boundaryGlobal - rowStart;
const bx = bi * BASE_SPACING;
root.appendChild(svg("line", {
class: "boundary",
x1: bx, y1: 4, x2: bx, y2: ROW_HEIGHT - 4,
}));
root.appendChild(svg("circle", {
class: "boundary-cap",
cx: bx, cy: 4, r: 2.4,
}));
}
// Rungs (one per base position)
for (let i = 0; i < n; i++) {
const { x, yTop, yBot } = geom(i);
const globalIdx = rowStart + i;
const kind = globalIdx < userLen ? "user" : "gen";
root.appendChild(svg("line", {
class: `rung ${kind}`,
x1: x, y1: yTop, x2: x, y2: yBot,
}));
}
// Base letters (top strand, then complement on bottom strand)
for (let i = 0; i < n; i++) {
const { x, yTop, yBot } = geom(i);
const b = rowBases[i];
const globalIdx = rowStart + i;
const kind = globalIdx < userLen ? "user" : "gen";
const top = svg("text", {
class: `base ${kind} ${b.base}`,
x, y: yTop,
});
top.textContent = b.base;
if (b.lp != null) top.setAttribute("aria-label", `${b.base} logp ${b.lp.toFixed(2)}`);
const comp = COMP[b.base] || "N";
const bot = svg("text", {
class: `base ${kind} ${comp}`,
x, y: yBot,
});
bot.textContent = comp;
root.appendChild(top);
root.appendChild(bot);
}
return root;
}
// Full redraw. Slices the strand into rows of `perRow` bases and
// builds one SVG per row. Profiled fine through 200 tokens Γ— 6 bp.
function redraw() {
dnaEl.innerHTML = "";
const total = state.user.length + state.gen.length;
refreshCounts();
if (total === 0) {
dnaEmpty.style.display = "block";
return;
}
dnaEmpty.style.display = "none";
const perRow = computeBasesPerRow();
const userLen = state.user.length;
const baseAt = (i) => i < userLen
? { base: state.user[i].base, lp: null }
: { base: state.gen[i - userLen].base, lp: state.gen[i - userLen].lp };
for (let rowStart = 0; rowStart < total; rowStart += perRow) {
const rowEnd = Math.min(rowStart + perRow, total);
const rowArr = [];
for (let i = rowStart; i < rowEnd; i++) rowArr.push(baseAt(i));
const block = document.createElement("div");
block.className = "row-block";
const pos = document.createElement("div");
pos.className = "row-pos";
pos.textContent = (rowStart + 1).toString();
block.appendChild(pos);
block.appendChild(buildRowSVG(rowStart, rowArr, userLen));
dnaEl.appendChild(block);
}
scrollToEnd();
}
function appendGeneratedBase(b, lp) {
state.gen.push({ base: b, lp });
redraw();
}
function refreshCounts() {
userCountEl.textContent = state.user.length;
genCountEl.textContent = state.gen.length;
totalBpEl.textContent = state.user.length + state.gen.length;
}
function scrollToEnd() {
requestAnimationFrame(() => {
dnaScroll.scrollTop = dnaScroll.scrollHeight;
});
}
// Reflow on resize so the wrap point tracks the viewport.
let _resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(redraw, 100);
});
// ════════════════════════════════════════════════════════════════════
// INPUT HANDLERS
// ════════════════════════════════════════════════════════════════════
function syncUserFromSeed() {
const cleaned = cleanDna(seed.value);
seedLen.textContent = `${cleaned.length} bp`;
state.user = cleaned.split("").map(b => ({ base: b }));
state.gen = []; // editing the seed invalidates prior generation
redraw();
}
function updateSliders() {
mtVal.textContent = mtIn.value;
tVal.textContent = (parseInt(tIn.value, 10) / 100).toFixed(2);
tpVal.textContent = (parseInt(tpIn.value, 10) / 100).toFixed(2);
}
seed.addEventListener("input", syncUserFromSeed);
mtIn.addEventListener("input", updateSliders);
tIn.addEventListener("input", updateSliders);
tpIn.addEventListener("input", updateSliders);
const PRESETS = {
atg: "ATGGCCATGGCC",
tata: "TATAAAATATAA",
random: () => Array.from({ length: 24 }, () => "ACGT"[Math.floor(Math.random() * 4)]).join(""),
};
document.querySelectorAll("[data-preset]").forEach(chip => {
chip.addEventListener("click", () => {
const p = PRESETS[chip.dataset.preset];
seed.value = typeof p === "function" ? p() : p;
syncUserFromSeed();
});
});
document.querySelectorAll("#meta-chips .chip").forEach(chip => {
chip.addEventListener("click", () => chip.classList.toggle("active"));
});
resetBtn.addEventListener("click", () => {
state.gen = [];
runStats = { tokens: 0, lpSum: 0, lpCount: 0, t0: 0 };
hud.status.textContent = "idle";
hud.status.className = "v";
hud.tok.textContent = "0";
hud.rate.textContent = "β€”";
hud.meanlp.textContent = "β€”";
redraw();
});
// ════════════════════════════════════════════════════════════════════
// BACKEND CONNECTION
// ════════════════════════════════════════════════════════════════════
async function initClient(retries = 4) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
client = await Client.connect(window.location.origin);
const cfg = await fetch("/app-info")
.then(r => r.ok ? r.json() : null).catch(() => null);
if (cfg?.model) {
modelInfoEl.textContent = `${cfg.model} Β· ${cfg.params_b}B params`;
} else {
modelInfoEl.textContent = "Carbon-3B Β· 3.45B params";
}
return;
} catch (e) {
console.warn(`connect attempt ${attempt}/${retries}`, e);
if (attempt < retries) {
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
} else {
showError("backend connect failed: " + (e?.message || e));
modelInfoEl.textContent = "offline";
}
}
}
}
initClient();
function extractData(msg) {
if (!msg) return null;
const candidates = [msg.data, msg.output, msg];
for (const c of candidates) {
if (c == null) continue;
if (Array.isArray(c)) {
for (const item of c) {
if (item && typeof item === "object" && "event" in item) return item;
}
} else if (typeof c === "object" && "event" in c) {
return c;
}
}
return null;
}
function handleEvent(ev) {
switch (ev.event) {
case "start":
hud.status.textContent = "streaming";
hud.status.className = "v streaming";
runStats = { tokens: 0, lpSum: 0, lpCount: 0, t0: performance.now() };
break;
case "token": {
const tokens = ev.tokens || [];
const lps = ev.logprobs || [];
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i];
const lp = lps[i];
if (typeof tok === "string" && /^[ACGT]{6}$/.test(tok)) {
for (let k = 0; k < 6; k++) appendGeneratedBase(tok[k], lp);
runStats.tokens += 1;
runStats.lpSum += lp;
runStats.lpCount += 1;
}
}
hud.tok.textContent = runStats.tokens;
const elapsed = (performance.now() - runStats.t0) / 1000;
if (elapsed > 0.2) hud.rate.textContent = (runStats.tokens / elapsed).toFixed(1);
hud.meanlp.textContent = runStats.lpCount
? (runStats.lpSum / runStats.lpCount).toFixed(2)
: "β€”";
break;
}
case "done":
hud.status.textContent = "done";
hud.status.className = "v done";
genBtn.disabled = false;
genBtn.textContent = "β–Ά Generate";
activeJob = null;
break;
case "error":
showError(ev.message || "unknown error");
hud.status.textContent = "error";
hud.status.className = "v error";
genBtn.disabled = false;
genBtn.textContent = "β–Ά Generate";
activeJob = null;
break;
}
}
async function startGenerate() {
if (!client) { showError("client not ready β€” wait a moment and retry"); return; }
const prompt = cleanDna(seed.value);
if (!prompt) { showError("seed must contain at least one DNA base"); return; }
state.gen = [];
redraw();
runStats = { tokens: 0, lpSum: 0, lpCount: 0, t0: performance.now() };
hud.status.textContent = "streaming";
hud.status.className = "v streaming";
hud.tok.textContent = "0";
hud.rate.textContent = "β€”";
hud.meanlp.textContent = "β€”";
genBtn.disabled = true;
genBtn.textContent = "● streaming";
const payload = {
prompt,
metadata: getMetadata(),
max_tokens: parseInt(mtIn.value, 10),
temperature: parseInt(tIn.value, 10) / 100,
top_p: parseInt(tpIn.value, 10) / 100,
};
try {
const job = client.submit("/generate", payload);
activeJob = job;
for await (const msg of job) {
const ev = extractData(msg);
if (ev) handleEvent(ev);
}
} catch (e) {
showError("stream failed: " + e.message);
console.error(e);
hud.status.textContent = "error";
hud.status.className = "v error";
genBtn.disabled = false;
genBtn.textContent = "β–Ά Generate";
activeJob = null;
}
}
genBtn.addEventListener("click", () => {
if (activeJob) {
try { activeJob.cancel?.(); } catch {}
activeJob = null;
hud.status.textContent = "cancelled";
hud.status.className = "v";
genBtn.disabled = false;
genBtn.textContent = "β–Ά Generate";
return;
}
startGenerate();
});
seed.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (!activeJob) startGenerate();
}
});
// ════════════════════════════════════════════════════════════════════
// INIT
// ════════════════════════════════════════════════════════════════════
updateSliders();
syncUserFromSeed();
</script>
</body>
</html>