singpredict-demo / index.html
pasindubg's picture
Update index.html
70ed716 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0C0C0F">
<title>SingPredict — Singlish Next-Word Prediction</title>
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.17.0/dist/ort.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0C0C0F; --surface: #16161A; --surface2: #1E1E24;
--border: #2A2A32; --text: #ECECF1; --text2: #8B8B9E;
--accent: #6C63FF; --accent-glow: rgba(108,99,255,0.15);
--green: #34D399; --red: #F87171; --orange: #FBBF24;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'DM Sans',sans-serif; background:var(--bg); color:var(--text); min-height:100vh; display:flex; flex-direction:column; align-items:center; }
.hero { text-align:center; padding:48px 24px 32px; max-width:640px; }
.hero h1 { font-size:2.2rem; font-weight:700; letter-spacing:-0.03em; margin-bottom:8px; }
.hero h1 span { color:var(--accent); }
.hero p { color:var(--text2); font-size:1rem; line-height:1.6; }
.status-bar { display:flex; align-items:center; gap:8px; justify-content:center; margin-top:16px; font-size:0.85rem; font-family:'JetBrains Mono',monospace; }
.status-dot { width:8px; height:8px; border-radius:50%; background:var(--orange); animation:pulse 1.5s ease-in-out infinite; }
.status-dot.ready { background:var(--green); animation:none; }
.status-dot.error { background:var(--red); animation:none; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.4;} }
.status-text { color:var(--text2); }
.input-section { width:100%; max-width:640px; padding:0 24px; margin-top:24px; }
.input-wrapper { background:var(--surface); border:1px solid var(--border); border-radius:16px; padding:20px; transition:border-color 0.2s; }
.input-wrapper:focus-within { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-glow); }
.input-label { font-size:0.75rem; text-transform:uppercase; letter-spacing:0.1em; color:var(--text2); margin-bottom:12px; display:block; }
#textInput { width:100%; background:transparent; border:none; color:var(--text); font-size:1.25rem; font-family:'DM Sans',sans-serif; outline:none; resize:none; min-height:80px; line-height:1.6; }
#textInput::placeholder { color:#4A4A58; }
.predictions { display:flex; gap:8px; margin-top:16px; min-height:40px; align-items:center; }
.pred-chip { padding:8px 16px; background:var(--surface2); border:1px solid var(--border); border-radius:10px; color:var(--text); font-size:0.95rem; font-weight:500; cursor:pointer; transition:all 0.15s; font-family:'JetBrains Mono',monospace; white-space:nowrap; }
.pred-chip:hover { background:var(--accent); border-color:var(--accent); color:white; transform:translateY(-1px); }
.pred-chip:first-child { background:var(--accent); border-color:var(--accent); color:white; }
.pred-chip:first-child:hover { background:#5B52E0; border-color:#5B52E0; }
.pred-chip.empty { border-style:dashed; color:var(--text2); cursor:default; font-style:italic; }
.pred-chip.empty:hover { background:var(--surface2); border-color:var(--border); color:var(--text2); transform:none; }
.mode-tag { font-size:0.7rem; padding:3px 8px; border-radius:6px; font-family:'JetBrains Mono',monospace; text-transform:uppercase; letter-spacing:0.05em; }
.mode-tag.completion { background:rgba(52,211,153,0.15); color:var(--green); }
.mode-tag.nextword { background:rgba(108,99,255,0.15); color:var(--accent); }
.stats-section { width:100%; max-width:640px; padding:0 24px; margin-top:24px; }
.stats-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
.stat-card { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:16px; text-align:center; }
.stat-value { font-size:1.4rem; font-weight:700; font-family:'JetBrains Mono',monospace; color:var(--accent); }
.stat-label { font-size:0.75rem; color:var(--text2); margin-top:4px; text-transform:uppercase; letter-spacing:0.05em; }
.keyboard-section { width:100%; max-width:480px; padding:0 12px; margin-top:24px; margin-bottom:32px; }
.keyboard { background:#D1D4DB; border-radius:12px; padding:8px 4px 12px; }
.key-row { display:flex; justify-content:center; margin:3px 0; }
.key { background:white; border:none; border-radius:5px; margin:0 3px; height:42px; min-width:32px; flex:1; max-width:38px; font-size:18px; font-family:'DM Sans',sans-serif; color:#1C1C1E; cursor:pointer; box-shadow:0 1px 0 #898A8D; transition:background 0.1s; display:flex; align-items:center; justify-content:center; }
.key:active { background:#B8BBC2; }
.key.special { background:#AEB3BD; font-size:14px; font-weight:500; flex:1.4; max-width:52px; }
.key.space { flex:6; max-width:none; font-size:14px; color:#8E8E93; }
.key.go { background:#007AFF; color:white; flex:1.5; max-width:56px; font-size:15px; font-weight:500; }
.key.num { flex:1.5; max-width:56px; font-size:14px; font-weight:500; background:#AEB3BD; }
.info-section { width:100%; max-width:640px; padding:0 24px; margin-top:24px; margin-bottom:48px; }
.info-card { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:20px; }
.info-card h3 { font-size:0.85rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--text2); margin-bottom:12px; }
.info-card p { color:var(--text2); font-size:0.9rem; line-height:1.6; }
.info-card code { background:var(--surface2); padding:2px 6px; border-radius:4px; font-family:'JetBrains Mono',monospace; font-size:0.85rem; color:var(--accent); }
.loading-overlay { position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(12,12,15,0.9); display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:100; gap:16px; }
.loading-overlay.hidden { display:none; }
.loading-spinner { width:40px;height:40px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; }
@keyframes spin { to{transform:rotate(360deg);} }
.loading-text { color:var(--text2); font-size:0.9rem; }
.loading-progress { width:200px; height:4px; background:var(--border); border-radius:2px; overflow:hidden; }
.loading-progress-bar { height:100%; background:var(--accent); border-radius:2px; transition:width 0.3s; width:0%; }
@media(max-width:480px) { .hero h1{font-size:1.6rem;} .stats-grid{grid-template-columns:repeat(3,1fr);gap:8px;} .stat-card{padding:12px;} .stat-value{font-size:1.1rem;} .key{height:38px;font-size:16px;} }
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">Loading model...</div>
<div class="loading-progress"><div class="loading-progress-bar" id="loadingBar"></div></div>
</div>
<div class="hero">
<h1><span>Sing</span>Predict</h1>
<p>Next-word prediction for Romanized Sinhala (Singlish)<br>Powered by DistilGPT-2 fine-tuned on 7.3M Singlish sentences</p>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span class="status-text" id="statusText">Loading model...</span>
</div>
</div>
<div class="input-section">
<div class="input-wrapper">
<span class="input-label">Type Singlish here</span>
<textarea id="textInput" placeholder="mama ada..." rows="3"></textarea>
<div class="predictions" id="predictions">
<span class="pred-chip empty">Start typing to see predictions</span>
</div>
</div>
</div>
<div class="stats-section">
<div class="stats-grid">
<div class="stat-card"><div class="stat-value" id="statLatency"></div><div class="stat-label">Latency</div></div>
<div class="stat-card"><div class="stat-value" id="statMode"></div><div class="stat-label">Mode</div></div>
<div class="stat-card"><div class="stat-value" id="statTokens"></div><div class="stat-label">Tokens</div></div>
</div>
</div>
<div class="keyboard-section"><div class="keyboard" id="keyboard"></div></div>
<div class="info-section">
<div class="info-card">
<h3>About this demo</h3>
<p>This demo runs the <code>DistilGPT-2</code> model (82M parameters, INT8 quantized) entirely in your browser using ONNX Runtime Web. No data is sent to any server. The model was fine-tuned on 7.3 million Romanized Sinhala sentences for 3 epochs.<br><br><strong>Try typing:</strong> "mama ada", "kohomada oya", "api inne", "mokakda une"</p>
</div>
</div>
<script>
// ============================================================
// BPE Tokenizer
// ============================================================
class SinglishTokenizer {
constructor(vocab, merges) {
this.vocab = vocab;
this.reverseVocab = {};
for (const [token, id] of Object.entries(vocab)) {
this.reverseVocab[id] = token;
}
this.mergeRanks = new Map();
merges.forEach((merge, idx) => { this.mergeRanks.set(merge, idx); });
this.unkId = vocab['<|endoftext|>'] || 0;
}
encode(text) {
if (!text || !text.trim()) return [];
const words = this.preTokenize(text);
const ids = [];
for (const word of words) {
for (const token of this.bpe(word)) {
ids.push(this.vocab[token] !== undefined ? this.vocab[token] : this.unkId);
}
}
return ids;
}
decode(ids) {
return ids.map(id => this.reverseVocab[id] || '').join('').replace(/Ġ/g, ' ').replace(/Ċ/g, '\n').trim();
}
// Get raw vocab token (preserves Ġ prefix)
getRawToken(id) { return this.reverseVocab[id] || null; }
preTokenize(text) {
const matches = text.match(/'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+/gu) || [];
return matches.map(word => [...word].map(ch => {
const c = ch.charCodeAt(0);
if (c === 32) return 'Ġ'; if (c === 10) return 'Ċ';
if (c >= 33 && c <= 126) return ch; if (c === 9) return 'ĉ'; return ch;
}).join(''));
}
bpe(word) {
if (!word) return []; if (word.length === 1) return [word];
let symbols = [...word];
while (symbols.length > 1) {
let bestPair = null, bestRank = Infinity;
for (let i = 0; i < symbols.length - 1; i++) {
const rank = this.mergeRanks.get(symbols[i] + ' ' + symbols[i+1]);
if (rank !== undefined && rank < bestRank) { bestRank = rank; bestPair = [symbols[i], symbols[i+1]]; }
}
if (!bestPair) break;
const ns = []; let i = 0;
while (i < symbols.length) {
if (i < symbols.length-1 && symbols[i]===bestPair[0] && symbols[i+1]===bestPair[1]) { ns.push(bestPair[0]+bestPair[1]); i+=2; }
else { ns.push(symbols[i]); i++; }
}
symbols = ns;
}
return symbols;
}
get vocabSize() { return Object.keys(this.vocab).length; }
}
// ============================================================
// Prediction Controller (matches Android architecture)
// ============================================================
class PredictionController {
constructor() {
this.session = null;
this.tokenizer = null;
this.ready = false;
this.cachedWords = [];
this.vocabLookup = null; // prefix → [{id, word}]
}
async load(modelUrl, vocabUrl, mergesUrl) {
updateLoading('Loading tokenizer...', 20);
const vocab = await (await fetch(vocabUrl)).json();
updateLoading('Loading merge rules...', 35);
const mergesText = await (await fetch(mergesUrl)).text();
const merges = mergesText.split('\n').filter(l => l && !l.startsWith('#')).map(l => l.trim()).filter(l => l.length > 0);
this.tokenizer = new SinglishTokenizer(vocab, merges);
// Build prefix lookup table (one-time)
updateLoading('Building vocabulary index...', 45);
this.vocabLookup = {};
for (let id = 0; id < this.tokenizer.vocabSize; id++) {
const raw = this.tokenizer.getRawToken(id);
if (!raw) continue;
const word = raw.startsWith('Ġ') ? this.tokenizer.decode([id]).trimStart() : this.tokenizer.decode([id]);
if (!word || word.length < 1 || word.startsWith('<') || word.startsWith('[')) continue;
const key = word.substring(0, 2).toLowerCase();
if (!this.vocabLookup[key]) this.vocabLookup[key] = [];
this.vocabLookup[key].push({ id, word });
// Also index by first char
const key1 = word.substring(0, 1).toLowerCase();
if (key1 !== key) {
if (!this.vocabLookup[key1]) this.vocabLookup[key1] = [];
this.vocabLookup[key1].push({ id, word });
}
}
const modelBuffer = await this.loadModelWithCache(modelUrl);
updateLoading('Initializing inference engine...', 90);
this.session = await ort.InferenceSession.create(modelBuffer, {
executionProviders: ['wasm'], graphOptimizationLevel: 'all',
});
this.ready = true;
updateLoading('Ready!', 100);
}
async predict(text, topK = 3) {
if (!this.ready || !text) return { predictions: [], mode: '', latency: 0, tokenCount: 0 };
const endsWithSpace = text.endsWith(' ');
const ids = this.tokenizer.encode(text.trimEnd());
if (ids.length === 0) return { predictions: [], mode: '', latency: 0, tokenCount: 0 };
const truncated = ids.length > 128 ? ids.slice(-128) : ids;
const start = performance.now();
const logits = await this.runInference(truncated);
const latency = performance.now() - start;
if (endsWithSpace) {
// ── NEXT-WORD MODE ──
const words = this.getNextWords(logits, topK);
this.cachedWords = words;
return {
predictions: words.map(w => ({ word: w, isCompletion: false })),
mode: 'next-word', latency: Math.round(latency), tokenCount: truncated.length
};
} else {
// ── COMPLETION MODE with prefix masking ──
const lastSpace = text.lastIndexOf(' ');
const partial = (lastSpace >= 0 ? text.substring(lastSpace + 1) : text).toLowerCase();
// Retain cached words that still match prefix
const retained = this.cachedWords.filter(w =>
w.toLowerCase().startsWith(partial) && w.toLowerCase() !== partial
);
const needed = topK - retained.length;
let newCompletions = [];
if (needed > 0) {
newCompletions = this.getCompletionsWithPrefixMask(logits, partial, needed + 5)
.filter(w => !retained.some(r => r.toLowerCase() === w.toLowerCase()))
.slice(0, needed);
}
// Retained first (stability), new fill remaining
const merged = [...retained, ...newCompletions]
.filter((w, i, arr) => arr.findIndex(x => x.toLowerCase() === w.toLowerCase()) === i)
.slice(0, topK);
this.cachedWords = merged;
return {
predictions: merged.map(w => ({ word: w, isCompletion: true })),
mode: 'completion', latency: Math.round(latency), tokenCount: truncated.length
};
}
}
// ── PREFIX MASK: scan vocab for tokens matching prefix, rank by logit score ──
getCompletionsWithPrefixMask(logits, partial, count) {
const candidates = [];
const prefixKey = partial.substring(0, 2);
// Check vocab tokens matching first 2 chars
const matching = this.vocabLookup[prefixKey] || [];
for (const { id, word } of matching) {
if (word.toLowerCase().startsWith(partial) && word.toLowerCase() !== partial && word.length >= 2) {
candidates.push({ score: logits[id], word });
}
}
// Also check single-char key
if (partial.length === 1) {
const single = this.vocabLookup[partial] || [];
for (const { id, word } of single) {
if (word.toLowerCase().startsWith(partial) && word.toLowerCase() !== partial && word.length >= 2 &&
!candidates.some(c => c.word.toLowerCase() === word.toLowerCase())) {
candidates.push({ score: logits[id], word });
}
}
}
// Also check continuation tokens from top logits
const topTokens = this.getTopK(logits, count * 5);
for (const [tokenId] of topTokens) {
const raw = this.tokenizer.getRawToken(tokenId);
if (!raw || raw.startsWith('Ġ') || raw.startsWith('<') || raw.startsWith('[')) continue;
const tokenText = this.tokenizer.decode([tokenId]);
if (!tokenText || tokenText.trim().length === 0) continue;
const fullWord = partial + tokenText.trim();
if (fullWord.length >= 2 && this.isCleanWord(fullWord) &&
!candidates.some(c => c.word.toLowerCase() === fullWord.toLowerCase())) {
candidates.push({ score: logits[tokenId], word: fullWord });
}
}
return candidates
.filter(c => this.isCleanWord(c.word))
.sort((a, b) => b.score - a.score)
.map(c => c.word)
.filter((w, i, arr) => arr.findIndex(x => x.toLowerCase() === w.toLowerCase()) === i)
.slice(0, count);
}
// ── NEXT-WORD: top tokens decoded as words ──
getNextWords(logits, topK) {
const topTokens = this.getTopK(logits, topK * 8);
const words = [];
for (const [tokenId] of topTokens) {
const raw = this.tokenizer.getRawToken(tokenId);
if (!raw) continue;
const text = this.tokenizer.decode([tokenId]).trimStart();
if (!text || text.length < 2 || text.startsWith('<') || text.startsWith('[')) continue;
if (this.isCleanWord(text) && !words.some(w => w.toLowerCase() === text.toLowerCase())) {
words.push(text);
}
if (words.length >= topK) break;
}
return words;
}
getTopK(logits, k) {
const indexed = [];
for (let i = 0; i < logits.length; i++) indexed.push([i, logits[i]]);
indexed.sort((a, b) => b[1] - a[1]);
return indexed.slice(0, k);
}
isCleanWord(word) {
if (!word || word.length < 2 || word.length > 25) return false;
if (word.startsWith('<') || word.startsWith('[')) return false;
let count = 1;
for (let i = 1; i < word.length; i++) {
if (word[i] === word[i-1]) { count++; if (count >= 3) return false; } else count = 1;
}
return true;
}
async runInference(ids) {
const seqLen = ids.length;
const hasBigInt64 = typeof BigInt64Array !== 'undefined';
let feeds;
if (hasBigInt64) {
feeds = {
'input_ids': new ort.Tensor('int64', new BigInt64Array(ids.map(id => BigInt(id))), [1, seqLen]),
'attention_mask': new ort.Tensor('int64', new BigInt64Array(seqLen).fill(1n), [1, seqLen]),
'position_ids': new ort.Tensor('int64', new BigInt64Array(seqLen).map((_, i) => BigInt(i)), [1, seqLen]),
};
} else {
feeds = {
'input_ids': new ort.Tensor('float32', new Float32Array(ids), [1, seqLen]),
'attention_mask': new ort.Tensor('float32', new Float32Array(seqLen).fill(1), [1, seqLen]),
'position_ids': new ort.Tensor('float32', new Float32Array(seqLen).map((_, i) => i), [1, seqLen]),
};
}
const results = await this.session.run(feeds);
const out = Object.values(results)[0];
const vocabSize = out.dims[2];
const offset = (seqLen - 1) * vocabSize;
return new Float32Array(out.data.buffer, offset * 4, vocabSize);
}
async loadModelWithCache(url) {
const CACHE_NAME = 'singpredict-model-v2';
if ('caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(url);
if (cached) {
updateLoading('Loading cached model...', 60);
const buf = await cached.arrayBuffer();
updateLoading('Model loaded from cache!', 85);
return buf;
}
updateLoading('Downloading model (cached after first load)...', 50);
const resp = await fetch(url);
const buf = await resp.arrayBuffer();
try { await cache.put(url, new Response(buf.slice(0))); } catch(e) {}
return buf;
} catch(e) { console.warn('Cache failed:', e); }
}
updateLoading('Downloading model...', 50);
return await (await fetch(url)).arrayBuffer();
}
}
// ============================================================
// UI
// ============================================================
const controller = new PredictionController();
let debounceTimer = null;
function updateLoading(text, pct) {
const el = document.getElementById('loadingText');
const bar = document.getElementById('loadingBar');
if (el) el.textContent = text;
if (bar) bar.style.width = pct + '%';
}
function setStatus(state, text) {
document.getElementById('statusDot').className = 'status-dot ' + state;
document.getElementById('statusText').textContent = text;
}
function showPredictions(result) {
const container = document.getElementById('predictions');
container.innerHTML = '';
if (!result || result.predictions.length === 0) {
container.innerHTML = '<span class="pred-chip empty">No predictions</span>';
return;
}
const tag = document.createElement('span');
tag.className = 'mode-tag ' + (result.mode === 'completion' ? 'completion' : 'nextword');
tag.textContent = result.mode === 'completion' ? 'Complete' : 'Next word';
container.appendChild(tag);
for (const pred of result.predictions) {
const chip = document.createElement('span');
chip.className = 'pred-chip';
chip.textContent = pred.word;
chip.onclick = () => {
const input = document.getElementById('textInput');
if (pred.isCompletion) {
const text = input.value;
const ls = text.lastIndexOf(' ');
input.value = (ls >= 0 ? text.substring(0, ls + 1) : '') + pred.word + ' ';
} else {
if (!input.value.endsWith(' ') && input.value.length > 0) input.value += ' ';
input.value += pred.word + ' ';
}
input.focus();
onInput();
};
container.appendChild(chip);
}
document.getElementById('statLatency').textContent = result.latency + 'ms';
document.getElementById('statMode').textContent = result.mode === 'completion' ? 'Complete' : 'NWP';
document.getElementById('statTokens').textContent = result.tokenCount;
}
async function onInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const text = document.getElementById('textInput').value;
if (!text.trim() && !text.endsWith(' ')) { showPredictions(null); return; }
try { showPredictions(await controller.predict(text)); } catch(e) { console.error(e); }
}, 150);
}
function buildKeyboard() {
const rows = [['q','w','e','r','t','y','u','i','o','p'],['a','s','d','f','g','h','j','k','l'],['shift','z','x','c','v','b','n','m','del'],['123','space','go']];
const kb = document.getElementById('keyboard'); kb.innerHTML = '';
for (const row of rows) {
const rd = document.createElement('div'); rd.className = 'key-row';
for (const key of row) {
const btn = document.createElement('button'); btn.className = 'key';
if (key==='shift'){btn.className+=' special';btn.textContent='⇧';}
else if(key==='del'){btn.className+=' special';btn.textContent='⌫';btn.onclick=()=>{const i=document.getElementById('textInput');i.value=i.value.slice(0,-1);i.focus();onInput();};}
else if(key==='space'){btn.className+=' space';btn.textContent='space';btn.onclick=()=>{const i=document.getElementById('textInput');if(!i.value.endsWith(' '))i.value+=' ';i.focus();onInput();};}
else if(key==='go'){btn.className+=' go';btn.textContent='Go';btn.onclick=()=>{document.getElementById('textInput').value+='\n';};}
else if(key==='123'){btn.className+=' num';btn.textContent='123';}
else{btn.textContent=key;btn.onclick=()=>{const i=document.getElementById('textInput');i.value+=key;i.focus();onInput();};}
rd.appendChild(btn);
}
kb.appendChild(rd);
}
}
async function init() {
buildKeyboard();
document.getElementById('textInput').addEventListener('input', onInput);
try {
await controller.load('./distilgpt2_singlish_int8.onnx', './vocab.json', './merges.txt');
document.getElementById('loadingOverlay').classList.add('hidden');
setStatus('ready', `Model loaded — ${controller.tokenizer.vocabSize.toLocaleString()} tokens`);
} catch(e) {
console.error(e);
updateLoading('Failed: ' + e.message, 0);
setStatus('error', 'Failed to load');
document.getElementById('loadingOverlay').classList.add('hidden');
}
}
init();
</script>
</body>
</html>