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