|
|
<!doctype html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="utf-8" /> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/> |
|
|
<title>NLLB translation in browser</title> |
|
|
<style> |
|
|
body { font:14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin:16px; } |
|
|
h1 { margin:0 0 12px } |
|
|
label { display:block; margin:8px 0 4px } |
|
|
textarea { width:100%; min-height:120px } |
|
|
select, input[type="submit"] { padding:8px; font:inherit } |
|
|
#row { display:grid; grid-template-columns:1fr 1fr; gap:12px } |
|
|
#progress-wrap { margin:10px 0; display:none } |
|
|
.muted { opacity:.8 } |
|
|
.ok { color:#0a0 } .err{ color:#b00 } .warn{ color:#b66 } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<h1>NLLB translation in browser</h1> |
|
|
|
|
|
<div id="row"> |
|
|
<div> |
|
|
<label for="src_lang">Source language</label> |
|
|
<select id="src_lang"> |
|
|
<option value="eng_Latn" selected>English (eng_Latn)</option> |
|
|
<option value="spa_Latn">Spanish (spa_Latn)</option> |
|
|
<option value="fra_Latn">French (fra_Latn)</option> |
|
|
<option value="hin_Deva">Hindi (hin_Deva)</option> |
|
|
</select> |
|
|
</div> |
|
|
<div> |
|
|
<label for="tgt_lang">Target language</label> |
|
|
<select id="tgt_lang"> |
|
|
<option value="spa_Latn" selected>Spanish (spa_Latn)</option> |
|
|
<option value="eng_Latn">English (eng_Latn)</option> |
|
|
<option value="fra_Latn">French (fra_Latn)</option> |
|
|
<option value="hin_Deva">Hindi (hin_Deva)</option> |
|
|
</select> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<label for="from">Input text</label> |
|
|
<textarea id="from" placeholder="Type text to translate"></textarea> |
|
|
|
|
|
<div id="progress-wrap"> |
|
|
<progress id="loadProgress" value="0" max="1" style="width:100%"></progress> |
|
|
<div id="progressText" class="muted">Initializing…</div> |
|
|
</div> |
|
|
|
|
|
<input type="submit" id="submit" value="Loading..." disabled /> |
|
|
|
|
|
<label for="to" style="margin-top:12px">Output</label> |
|
|
<textarea id="to" readonly>Output will be here...</textarea> |
|
|
|
|
|
<div id="status" class="muted" style="margin-top:8px">Loading library…</div> |
|
|
|
|
|
<script> |
|
|
const statusEl = document.getElementById('status'); |
|
|
|
|
|
async function loadTransformers() { |
|
|
|
|
|
const cdns = [ |
|
|
'https://cdn.jsdelivr.net/npm/@xenova/transformers@3.0.0', |
|
|
'https://unpkg.com/@xenova/transformers@3.0.0' |
|
|
]; |
|
|
for (const url of cdns) { |
|
|
try { |
|
|
statusEl.textContent = `Trying ESM: ${url}`; |
|
|
const mod = await import( url); |
|
|
if (mod?.pipeline) { |
|
|
window.transformers = mod; |
|
|
statusEl.textContent = `✅ Loaded via ESM: ${url}`; |
|
|
return; |
|
|
} |
|
|
} catch (_) {} |
|
|
} |
|
|
|
|
|
|
|
|
statusEl.textContent = 'Trying local UMD ./transformers.min.js…'; |
|
|
await new Promise((resolve) => { |
|
|
const s = document.createElement('script'); |
|
|
s.src = './transformers.min.js?v=' + Math.random(); |
|
|
s.onload = resolve; s.onerror = resolve; |
|
|
document.head.appendChild(s); |
|
|
}); |
|
|
if (window.transformers?.pipeline) { |
|
|
statusEl.textContent = '✅ Loaded via local UMD (./transformers.min.js)'; |
|
|
return; |
|
|
} |
|
|
statusEl.textContent = '❌ Could not load Transformers.js (ESM or UMD).'; |
|
|
} |
|
|
|
|
|
function showProgressUI(show, message) { |
|
|
const wrap = document.getElementById("progress-wrap"); |
|
|
const txt = document.getElementById("progressText"); |
|
|
const bar = document.getElementById("loadProgress"); |
|
|
wrap.style.display = show ? "" : "none"; |
|
|
if (message) txt.textContent = message; |
|
|
bar.value = 0; bar.max = 1; |
|
|
} |
|
|
|
|
|
function progressCallback(p) { |
|
|
const txt = document.getElementById("progressText"); |
|
|
const bar = document.getElementById("loadProgress"); |
|
|
if (p.status) txt.textContent = p.status; |
|
|
if (typeof p.loaded === "number" && typeof p.total === "number" && p.total > 0) { |
|
|
bar.max = p.total; bar.value = p.loaded; |
|
|
const pct = Math.round((p.loaded / p.total) * 100); |
|
|
txt.textContent = `Downloading ${p.file || "model"}… ${pct}%`; |
|
|
} |
|
|
} |
|
|
|
|
|
(async () => { |
|
|
await loadTransformers(); |
|
|
const tf = window.transformers; |
|
|
if (!(tf && tf.pipeline)) return; |
|
|
|
|
|
const btn = document.getElementById("submit"); |
|
|
const out = document.getElementById("to"); |
|
|
const fromEl = document.getElementById("from"); |
|
|
const srcEl = document.getElementById("src_lang"); |
|
|
const tgtEl = document.getElementById("tgt_lang"); |
|
|
|
|
|
|
|
|
const { env } = tf; |
|
|
env.useBrowserCache = true; |
|
|
env.allowLocalModels = false; |
|
|
env.backends.onnx.wasm.numThreads = Math.max(4, Math.min(8, navigator.hardwareConcurrency || 4)); |
|
|
|
|
|
btn.disabled = false; |
|
|
btn.value = "Translate"; |
|
|
|
|
|
let translator = null; |
|
|
async function getTranslator() { |
|
|
if (translator) return translator; |
|
|
showProgressUI(true, "Loading model…"); |
|
|
translator = await tf.pipeline("translation", "Xenova/nllb-200-distilled-600M", { |
|
|
device: (navigator.gpu ? "webgpu" : "wasm"), |
|
|
progress_callback: progressCallback |
|
|
}); |
|
|
document.getElementById("progressText").textContent = "✅ Model ready"; |
|
|
const bar = document.getElementById("loadProgress"); bar.max = 1; bar.value = 1; |
|
|
return translator; |
|
|
} |
|
|
|
|
|
btn.addEventListener("click", async () => { |
|
|
const prev = btn.value; |
|
|
btn.disabled = true; btn.value = "Working…"; |
|
|
try { |
|
|
const t = await getTranslator(); |
|
|
const text = (fromEl.value || "").trim(); |
|
|
if (!text) { out.value = "Please type some text."; return; } |
|
|
const res = await t(text, { src_lang: srcEl.value, tgt_lang: tgtEl.value, max_length: 128 }); |
|
|
out.value = res?.[0]?.translation_text || "(no output)"; |
|
|
} catch (e) { |
|
|
console.error(e); |
|
|
out.value = "❌ Translation failed. See console."; |
|
|
} finally { |
|
|
btn.disabled = false; btn.value = prev; |
|
|
} |
|
|
}); |
|
|
})(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |