aetherstory-space / index.html
wincode's picture
Upload folder using huggingface_hub
2608cc9 verified
Raw
History Blame Contribute Delete
10.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AetherStory — In-Browser Storyteller</title>
<style>
:root {
--bg: #0f0f1a;
--card: #1a1a2e;
--accent: #8b5cf6;
--accent2: #ec4899;
--text: #e5e7eb;
--muted: #9ca3af;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, var(--bg) 0%, #1f1f3a 100%);
color: var(--text);
min-height: 100vh;
}
.container { max-width: 820px; margin: 0 auto; padding: 2.5rem 1.5rem; }
h1 {
font-size: 2.5rem; margin: 0 0 0.3rem; text-align: center;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.tagline { text-align: center; color: var(--muted); margin-bottom: 2rem; font-size: 0.95rem; }
.status {
display: inline-block; padding: 0.3rem 0.8rem; border-radius: 999px;
font-size: 0.8rem; margin-bottom: 1rem;
background: rgba(139,92,246,0.15); border: 1px solid rgba(139,92,246,0.3);
}
.status.error { background: rgba(239,68,68,0.15); border-color: rgba(239,68,68,0.3); }
.status.ready { background: rgba(34,197,94,0.15); border-color: rgba(34,197,94,0.3); }
.card {
background: var(--card); border-radius: 14px; padding: 1.5rem;
margin-bottom: 1.2rem; border: 1px solid rgba(255,255,255,0.06);
box-shadow: 0 4px 24px rgba(0,0,0,0.2);
}
label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.4rem; }
textarea, input[type=range], input[type=number] {
width: 100%; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1);
color: var(--text); border-radius: 8px; padding: 0.7rem; font-size: 0.95rem;
font-family: inherit;
}
textarea { min-height: 70px; resize: vertical; }
.row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
.row label { font-size: 0.75rem; }
.row .val { color: var(--accent); font-weight: 600; }
button {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: white; border: none; padding: 0.85rem 1.5rem; border-radius: 8px;
font-size: 1rem; font-weight: 600; cursor: pointer; width: 100%;
transition: transform 0.1s, opacity 0.2s;
}
button:hover:not(:disabled) { transform: translateY(-1px); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.story {
background: rgba(0,0,0,0.25); border-radius: 10px; padding: 1.2rem;
min-height: 180px; line-height: 1.7; font-size: 1.02rem;
white-space: pre-wrap; font-family: Georgia, 'Times New Roman', serif;
}
.story .prompt { color: var(--accent); }
.examples { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
.example {
background: rgba(139,92,246,0.1); border: 1px solid rgba(139,92,246,0.25);
color: var(--text); padding: 0.4rem 0.8rem; border-radius: 999px;
font-size: 0.8rem; cursor: pointer; transition: background 0.15s;
}
.example:hover { background: rgba(139,92,246,0.25); }
.meta { font-size: 0.8rem; color: var(--muted); text-align: center; margin-top: 1.5rem; }
.meta a { color: var(--accent); text-decoration: none; }
.cursor::after { content: '▍'; color: var(--accent); animation: blink 1s infinite; }
@keyframes blink { 50% { opacity: 0; } }
</style>
</head>
<body>
<div class="container">
<h1>📖 AetherStory</h1>
<p class="tagline">A from-scratch, CPU-trained transformer — now running live in your browser via ONNX</p>
<div style="text-align:center">
<span id="status" class="status">⏳ Loading model (20 KB)…</span>
</div>
<div class="card">
<label>Opening prompt</label>
<textarea id="prompt" placeholder="In the Glasslands there lived">In the Glasslands there lived</textarea>
<div class="examples">
<span class="example">In the Glasslands there lived</span>
<span class="example">Once upon a time, in the Hollow Mountain,</span>
<span class="example">On the longest night of the year,</span>
<span class="example">Few in the Bramblewilds knew the name of</span>
<span class="example">To understand the Whispering Tundra,</span>
</div>
</div>
<div class="card">
<div class="row">
<div>
<label>Temperature: <span id="tempVal" class="val">0.9</span></label>
<input type="range" id="temp" min="0.2" max="1.5" step="0.05" value="0.9" />
</div>
<div>
<label>Top-k: <span id="topkVal" class="val">40</span></label>
<input type="range" id="topk" min="1" max="100" step="1" value="40" />
</div>
<div>
<label>Max tokens: <span id="maxVal" class="val">100</span></label>
<input type="range" id="maxtok" min="20" max="200" step="10" value="100" />
</div>
</div>
</div>
<div class="card">
<button id="genBtn" disabled>✨ Generate Story</button>
</div>
<div class="card">
<div id="story" class="story"><span style="color:var(--muted)">Your generated fable will appear here…</span></div>
</div>
<div class="meta">
Model: <a href="https://huggingface.co/wincode/aetherstory" target="_blank">wincode/aetherstory</a> ·
~863K params · trained on CPU ·
<a href="https://huggingface.co/datasets/wincode/aetherstory-data" target="_blank">dataset</a>
</div>
</div>
<!-- onnxruntime-web from CDN -->
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.18.0/dist/ort.min.js"></script>
<script>
const STATUS = document.getElementById('status');
const GEN_BTN = document.getElementById('genBtn');
const STORY = document.getElementById('story');
const PROMPT = document.getElementById('prompt');
const TEMP = document.getElementById('temp');
const TOPK = document.getElementById('topk');
const MAXTOK = document.getElementById('maxtok');
// slider live readouts
const bind = (el, fn) => { el.addEventListener('input', () => fn(el.value)); };
bind(TEMP, v => document.getElementById('tempVal').textContent = parseFloat(v).toFixed(2));
bind(TOPK, v => document.getElementById('topkVal').textContent = v);
bind(MAXTOK, v => document.getElementById('maxVal').textContent = v);
// example prompts
document.querySelectorAll('.example').forEach(el => {
el.addEventListener('click', () => { PROMPT.value = el.textContent; });
});
let session = null;
let vocab = null, id2word = null, w2id = null;
let specials = null, maxSeqLen = 64;
// regex must match the python tokenizer: \w+ | [^\w\s] | \s+
const TOKEN_RE = /\w+|[^\w\s]|\s+/g;
function encode(text) {
const ids = [specials['<bos>']];
for (const m of text.toLowerCase().matchAll(TOKEN_RE)) {
const t = m[0];
if (!t.trim()) continue;
ids.push(w2id[t] !== undefined ? w2id[t] : specials['<unk>']);
}
return ids;
}
function decode(ids) {
const out = [];
for (const i of ids) {
if (i === specials['<bos>'] || i === specials['<pad>']) continue;
if (i === specials['<eos>']) break;
const w = id2word[i];
if (!w || (w.startsWith('<') && w.endsWith('>'))) continue;
out.push(w);
}
let t = out.join(' ');
// detach punctuation (mirror python detokeniser)
t = t.replace(/\s+([,.;:!?'”’)])/g, '$1');
t = t.replace(/([“‘(])\s+/g, '$1');
return t;
}
function setStatus(text, kind) {
STATUS.textContent = text;
STATUS.className = 'status' + (kind ? ' ' + kind : '');
}
async function init() {
try {
setStatus('⏳ Loading tokenizer…');
const resp = await fetch('tokenizer_bundle.json');
const bundle = await resp.json();
vocab = bundle.vocab;
specials = bundle.special_tokens;
maxSeqLen = bundle.config.max_seq_len;
w2id = {};
for (const [w, i] of Object.entries(specials)) w2id[w] = i;
vocab.forEach((w, i) => { if (!(i in w2id) && !(w in w2id)) w2id[w] = i; });
id2word = {};
for (const [w, i] of Object.entries(w2id)) id2word[i] = w;
setStatus('⏳ Loading model (20 KB)…');
session = await ort.InferenceSession.create('aetherstory.onnx', {
executionProviders: ['wasm'],
graphOptimizationLevel: 'all',
});
setStatus('✅ Ready — 863K params loaded in-browser', 'ready');
GEN_BTN.disabled = false;
} catch (e) {
console.error(e);
setStatus('❌ Failed to load: ' + e.message, 'error');
}
}
function softmax(logits, temperature) {
const max = Math.max(...logits);
const exps = logits.map(x => Math.exp((x - max) / temperature));
const sum = exps.reduce((a, b) => a + b, 0);
return exps.map(e => e / sum);
}
function sampleFrom(probs, topK) {
// top-k sampling
const idx = probs.map((p, i) => [p, i]).sort((a, b) => b[0] - a[0]).slice(0, topK);
const renorm = idx.map(([p]) => p);
const sum = renorm.reduce((a, b) => a + b, 0);
let r = Math.random() * sum;
for (const [p, i] of idx) {
r -= p / sum * sum; // keep original scale
if (r <= 0) return i;
}
return idx[idx.length - 1][1];
}
async function generate() {
if (!session) return;
const promptText = PROMPT.value.trim() || 'In the';
const temperature = parseFloat(TEMP.value);
const topK = parseInt(TOPK.value);
const maxNew = parseInt(MAXTOK.value);
GEN_BTN.disabled = true;
STORY.innerHTML = '<span class="prompt"></span><span class="cursor"></span>';
const promptSpan = STORY.querySelector('.prompt');
const cursor = STORY.querySelector('.cursor');
let ids = encode(promptText);
const promptDisplay = decode(ids);
promptSpan.textContent = promptDisplay + ' ';
for (let step = 0; step < maxNew; step++) {
// crop to max seq len from the right
const ctx = ids.length > maxSeqLen ? ids.slice(ids.length - maxSeqLen) : ids;
const input = new ort.Tensor('int64', BigInt64Array.from(ctx.map(x => BigInt(x))), [1, ctx.length]);
try {
const out = await session.run({ tokens: input });
const logits = out.logits.data;
// last position only
const vocabSize = vocab.length;
const last = Array.from(logits.slice((ctx.length - 1) * vocabSize, ctx.length * vocabSize));
const probs = softmax(last, temperature);
const nextId = sampleFrom(probs, topK);
ids.push(nextId);
// update story display
const newText = decode(ids);
cursor.previousSibling.textContent = promptDisplay + ' ' + newText.slice(promptDisplay.length).trimStart();
if (nextId === specials['<eos>']) break;
} catch (e) {
console.error(e);
setStatus('❌ Generation error: ' + e.message, 'error');
break;
}
}
// final render without cursor
STORY.innerHTML = '<span class="prompt">' + promptDisplay + '</span> ' +
decode(ids).slice(promptDisplay.length).trimStart();
GEN_BTN.disabled = false;
}
GEN_BTN.addEventListener('click', generate);
init();
</script>
</body>
</html>