Spaces:
Running
Running
| <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> | |