| import { Wllama } from 'https://cdn.jsdelivr.net/npm/@wllama/wllama@2.3.1/esm/index.js'; |
|
|
| |
| const MODEL_MAX_CONTEXT = 512; |
| const MODEL_FILE = 'model_f16.gguf'; |
| const textDecoder = new TextDecoder(); |
|
|
| |
| let wllama = null; |
| let engineReady = false; |
| let isGenerating = false; |
| let userStopped = false; |
| let abortController = null; |
| let progressTimer = null; |
|
|
| |
| const deviceBadge = document.getElementById('deviceBadge'); |
| const promptInput = document.getElementById('promptInput'); |
| const generateBtn = document.getElementById('generateBtn'); |
| const generateBtnText = document.getElementById('generateBtnText'); |
| const stopBtn = document.getElementById('stopBtn'); |
| const copyBtn = document.getElementById('copyBtn'); |
| const clearBtn = document.getElementById('clearBtn'); |
| const outputBox = document.getElementById('outputBox'); |
| const statusText = document.getElementById('statusText'); |
| const tempInput = document.getElementById('temperature'); |
| const tempVal = document.getElementById('tempValue'); |
| const progressOverlay = document.getElementById('progressOverlay'); |
| const progressFill = document.getElementById('progressFill'); |
| const progressPct = document.getElementById('progressPct'); |
| const progressLabel = document.getElementById('progressLabel'); |
| const progressSub = document.getElementById('progressSub'); |
| const promptTokenMetric = document.getElementById('promptTokenMetric'); |
| const generatedTokenMetric = document.getElementById('generatedTokenMetric'); |
| const speedMetric = document.getElementById('speedMetric'); |
| const timeMetric = document.getElementById('timeMetric'); |
|
|
| |
| tempInput.addEventListener('input', (e) => |
| tempVal.textContent = parseFloat(e.target.value).toFixed(2)); |
|
|
| |
| |
| |
| function showProgress(label = 'Downloading modelβ¦', sub = 'FP16 Β· 43 MB Β· first run only') { |
| progressLabel.textContent = label; |
| progressSub.textContent = sub; |
| setProgress(0); |
| |
| clearTimeout(progressTimer); |
| progressTimer = setTimeout(() => { |
| progressOverlay.classList.remove('hidden'); |
| }, 400); |
| } |
|
|
| function setProgress(pct, loaded, total) { |
| const p = Math.min(100, Math.max(0, pct)); |
| progressFill.style.width = `${p}%`; |
| progressPct.textContent = `${Math.round(p)}%`; |
| if (typeof loaded === 'number' && typeof total === 'number' && total > 0) { |
| const mb = (total / 1024 / 1024).toFixed(0); |
| const done = (loaded / 1024 / 1024).toFixed(1); |
| progressSub.textContent = `${done} / ${mb} MB downloaded`; |
| } |
| } |
|
|
| function hideProgress() { |
| clearTimeout(progressTimer); |
| progressTimer = null; |
| progressOverlay.classList.add('hidden'); |
| setProgress(0); |
| } |
|
|
| |
| |
| |
| async function initializeEngine() { |
| deviceBadge.textContent = 'Loadingβ¦'; |
| deviceBadge.className = 'badge loading'; |
| showProgress('Downloading modelβ¦', 'FP16 Β· 43 MB Β· cached after first run'); |
|
|
| const CONFIG_PATHS = { |
| 'single-thread/wllama.wasm': 'https://cdn.jsdelivr.net/npm/@wllama/wllama@2.3.1/src/single-thread/wllama.wasm', |
| 'multi-thread/wllama.wasm': 'https://cdn.jsdelivr.net/npm/@wllama/wllama@2.3.1/src/multi-thread/wllama.wasm', |
| }; |
|
|
| wllama = new Wllama(CONFIG_PATHS); |
|
|
| const nThreads = Math.min(8, navigator.hardwareConcurrency || 4); |
| const modelUrl = new URL(`./${MODEL_FILE}`, import.meta.url).href; |
|
|
| await wllama.loadModelFromUrl(modelUrl, { |
| n_ctx: MODEL_MAX_CONTEXT, |
| n_threads: nThreads, |
| cache_type_k: 'f32', |
| cache_type_v: 'f32', |
| progressCallback: ({ loaded, total }) => { |
| if (total > 0) setProgress((loaded / total) * 100, loaded, total); |
| }, |
| }); |
|
|
| hideProgress(); |
| engineReady = true; |
| deviceBadge.className = 'badge webgpu'; |
| deviceBadge.textContent = 'FP16 Β· WASM'; |
| console.log('Wllama engine ready.'); |
| } |
|
|
| |
| |
| |
| async function updatePromptMetric() { |
| if (!wllama) return; |
| try { |
| const tokens = await wllama.tokenize(promptInput.value || ''); |
| promptTokenMetric.textContent = `${tokens.length} prompt tok`; |
| } catch (_) {} |
| } |
|
|
| promptInput.addEventListener('input', updatePromptMetric); |
|
|
| |
| |
| |
| async function handleGenerate() { |
| if (isGenerating) return; |
|
|
| const promptText = promptInput.value.trim(); |
| if (!promptText) { promptInput.focus(); return; } |
|
|
| |
| if (!engineReady) { |
| generateBtn.disabled = true; |
| generateBtnText.textContent = 'Loadingβ¦'; |
| statusText.textContent = ''; |
| try { |
| await initializeEngine(); |
| } catch (err) { |
| console.error('Engine load failed:', err); |
| deviceBadge.className = 'badge wasm'; |
| deviceBadge.textContent = 'Error'; |
| statusText.textContent = 'Failed to load model.'; |
| generateBtn.disabled = false; |
| generateBtnText.textContent = 'Retry'; |
| hideProgress(); |
| return; |
| } |
| generateBtn.disabled = false; |
| generateBtnText.textContent = 'Generate'; |
| } |
|
|
| await runGeneration(promptText); |
| } |
|
|
| |
| |
| |
| async function runGeneration(promptText) { |
| const temperature = parseFloat(tempInput.value); |
| const maxNewTokens = MODEL_MAX_CONTEXT - 20; |
|
|
| isGenerating = true; |
| userStopped = false; |
| abortController = new AbortController(); |
|
|
| generateBtn.disabled = true; |
| generateBtnText.textContent = 'Generatingβ¦'; |
| stopBtn.disabled = false; |
| statusText.textContent = ''; |
|
|
| |
| outputBox.innerHTML = ''; |
| const promptSpan = document.createElement('span'); |
| promptSpan.className = 'prompt-text'; |
| promptSpan.textContent = promptText; |
| const generatedNode = document.createTextNode(''); |
| const cursorSpan = document.createElement('span'); |
| cursorSpan.className = 'cursor'; |
| cursorSpan.textContent = '|'; |
| outputBox.appendChild(promptSpan); |
| outputBox.appendChild(generatedNode); |
| outputBox.appendChild(cursorSpan); |
|
|
| let count = 0; |
| const t0 = performance.now(); |
| const eosId = wllama.getEOS(); |
|
|
| try { |
| await wllama.kvClear(); |
|
|
| |
| const rawTokens = await wllama.tokenize(promptText, false); |
| const promptTokens = [wllama.getBOS(), ...rawTokens]; |
|
|
| await wllama.decode(promptTokens, { skipLogits: false }); |
| await wllama.samplingInit({ |
| temp: temperature, |
| top_k: 40, |
| top_p: 0.90, |
| penalty_repeat: 1.15, |
| penalty_last_n: 256, |
| penalty_present: 0.5, |
| penalty_freq: 0.5, |
| }, promptTokens); |
|
|
| for (let i = 0; i < maxNewTokens; i++) { |
| if (userStopped || abortController.signal.aborted) break; |
|
|
| const sampled = await wllama.samplingSample(); |
| const tok = sampled.token; |
| if (tok === eosId || tok === 3 || tok === 0) break; |
|
|
| await wllama.samplingAccept([tok]); |
|
|
| const piece = typeof sampled.piece === 'string' |
| ? sampled.piece |
| : textDecoder.decode(sampled.piece, { stream: true }); |
|
|
| generatedNode.data += piece; |
| count++; |
|
|
| await wllama.decode([tok], { skipLogits: false }); |
|
|
| const elapsed = (performance.now() - t0) / 1000; |
| if (elapsed > 0) { |
| generatedTokenMetric.textContent = `${count} tok generated`; |
| speedMetric.textContent = `${(count / elapsed).toFixed(1)} tok/s`; |
| timeMetric.textContent = `${elapsed.toFixed(1)}s`; |
| } |
| outputBox.scrollTop = outputBox.scrollHeight; |
| } |
|
|
| statusText.textContent = userStopped ? 'Stopped' : 'Done'; |
| await updatePromptMetric(); |
|
|
| } catch (err) { |
| statusText.textContent = userStopped ? 'Stopped' : 'Error'; |
| if (!userStopped) console.error(err); |
| } finally { |
| isGenerating = false; |
| generateBtn.disabled = false; |
| generateBtnText.textContent = 'Generate'; |
| stopBtn.disabled = true; |
| cursorSpan.remove(); |
| } |
| } |
|
|
| |
| |
| |
| generateBtn.addEventListener('click', handleGenerate); |
|
|
| stopBtn.addEventListener('click', () => { |
| userStopped = true; |
| abortController?.abort(); |
| }); |
|
|
| copyBtn.addEventListener('click', () => { |
| const text = outputBox.innerText.replace(/\|$/, ''); |
| navigator.clipboard.writeText(text).then(() => { |
| const orig = copyBtn.textContent; |
| copyBtn.textContent = 'Copied!'; |
| setTimeout(() => copyBtn.textContent = orig, 1800); |
| }); |
| }); |
|
|
| clearBtn.addEventListener('click', () => { |
| if (isGenerating) return; |
| promptInput.value = ''; |
| outputBox.innerHTML = '<span class="placeholder-text">ΰ€ΰ€ͺΰ€ΰ₯ ΰ€ΰ€Ήΰ€Ύΰ€¨ΰ₯ ΰ€―ΰ€Ήΰ€Ύΰ€ ΰ€¦ΰ€Ώΰ€ΰ€Ύΰ€ ΰ€¦ΰ₯ΰ€ΰ₯β¦</span>'; |
| promptTokenMetric.textContent = 'β'; |
| generatedTokenMetric.textContent = 'β'; |
| speedMetric.textContent = 'β'; |
| timeMetric.textContent = 'β'; |
| statusText.textContent = ''; |
| }); |
|
|
| document.querySelectorAll('.sample-btn').forEach(btn => { |
| btn.addEventListener('click', () => { |
| if (isGenerating) return; |
| const text = btn.getAttribute('data-prompt'); |
| promptInput.value = text; |
| outputBox.innerHTML = `<span class="prompt-text">${text}</span><span class="cursor">|</span>`; |
| promptInput.focus(); |
| if (engineReady) updatePromptMetric(); |
| }); |
| }); |
|
|