Files
finrod/static/index.html
João Pedro Battistella Nadas 732982e66c feat: show which model is currently loaded in Ollama
- /api/loaded backend endpoint proxies Ollama /api/ps and returns
  {models: [{name, expires_at}]}
- Status line under the controls shows "Hot: gemma2:9b — unloads in 4m"
  with a glowing orange dot, or "No model loaded" in muted style
- Loaded models in the dropdown get a "● " prefix
- Refreshes on page load, after every translation, and 500ms after the
  user pauses typing in the input box
2026-06-09 15:51:57 +02:00

428 lines
13 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>finrod — translator</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cg fill='%23d4a566'%3E%3Cpath d='M16 1 L18 14 L31 16 L18 18 L16 31 L14 18 L1 16 L14 14 Z'/%3E%3Cpath d='M16 4 L22 16 L16 28 L10 16 Z' opacity='.55'/%3E%3C/g%3E%3C/svg%3E" />
<style>
:root {
color-scheme: light dark;
--bg: #faf8f3;
--fg: #2b2620;
--muted: #6b6358;
--accent: #7a5a2f;
--border: #d8d0bf;
--panel: #fff;
--error: #a13b2c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1c1a17;
--fg: #e6dfd0;
--muted: #968c7a;
--accent: #d4a566;
--border: #3a352e;
--panel: #25221d;
--error: #e08070;
}
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
margin: 0;
padding: 2rem 1rem;
}
.wrap { max-width: 960px; margin: 0 auto; }
h1 {
font-weight: 300;
letter-spacing: 0.05em;
margin: 0 0 0.25rem 0;
}
.sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
.controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.controls { grid-template-columns: 1fr; }
}
label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.08em; }
select, textarea, button {
font: inherit;
color: var(--fg);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem 0.75rem;
width: 100%;
}
textarea { resize: vertical; min-height: 180px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.panes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 640px) {
.panes { grid-template-columns: 1fr; }
}
button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
padding: 0.7rem 1.5rem;
font-weight: 500;
letter-spacing: 0.05em;
width: auto;
margin: 1rem 0;
}
button:disabled { opacity: 0.5; cursor: progress; }
.output {
white-space: pre-wrap;
min-height: 180px;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.meta { font-size: 0.8rem; color: var(--muted); margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
.error { color: var(--error); }
.output.streaming {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 0 12px -4px var(--accent);
}
.caret {
display: inline-block;
width: 0.5em;
height: 1em;
vertical-align: text-bottom;
background: var(--accent);
margin-left: 1px;
animation: blink 1s steps(2, start) infinite;
}
@keyframes blink { to { opacity: 0; } }
.spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.pulse {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
.hot-status {
font-size: 0.8rem;
color: var(--muted);
margin: 0.25rem 0 0.75rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 1.2em;
}
.hot-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #e07a3e;
box-shadow: 0 0 6px #e07a3e;
}
.hot-status.idle { opacity: 0.55; }
.hot-status.idle .hot-dot {
background: var(--border);
box-shadow: none;
}
</style>
</head>
<body>
<div class="wrap">
<h1>finrod</h1>
<div class="sub">Translation via Ollama · loremaster of tongues</div>
<div class="controls">
<div>
<label for="source">From</label>
<select id="source"></select>
</div>
<div>
<label for="target">To</label>
<select id="target"></select>
</div>
<div>
<label for="model">Model</label>
<select id="model"></select>
</div>
</div>
<div id="hot-status" class="hot-status idle">
<span class="hot-dot"></span>
<span id="hot-status-text">No model loaded</span>
</div>
<div class="panes">
<div>
<label for="input">Input</label>
<textarea id="input" placeholder="Type or paste text to translate…"></textarea>
</div>
<div>
<label for="output">Translation</label>
<div id="output" class="output"></div>
</div>
</div>
<button id="go">Translate</button>
<div id="meta" class="meta"></div>
</div>
<script>
const LANGUAGES = [
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
"French", "German", "Italian", "Dutch", "Polish", "Russian",
"Japanese", "Korean", "Chinese (Simplified)", "Chinese (Traditional)",
"Arabic", "Hindi", "Turkish", "Swedish", "Norwegian", "Danish",
"Finnish", "Greek", "Hebrew", "Czech", "Hungarian", "Romanian",
"Ukrainian", "Vietnamese", "Thai", "Indonesian",
];
const sourceSel = document.getElementById("source");
const targetSel = document.getElementById("target");
const modelSel = document.getElementById("model");
const input = document.getElementById("input");
const output = document.getElementById("output");
const goBtn = document.getElementById("go");
const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status");
const hotStatusText = document.getElementById("hot-status-text");
function fillLangs() {
const auto = document.createElement("option");
auto.value = "auto";
auto.textContent = "Auto-detect";
sourceSel.appendChild(auto);
for (const lang of LANGUAGES) {
const o1 = document.createElement("option");
o1.value = lang; o1.textContent = lang;
sourceSel.appendChild(o1);
const o2 = document.createElement("option");
o2.value = lang; o2.textContent = lang;
targetSel.appendChild(o2);
}
sourceSel.value = "auto";
targetSel.value = "English";
}
async function loadModels() {
try {
const r = await fetch("/api/models");
if (!r.ok) throw new Error(await r.text());
const models = await r.json();
if (models.length === 0) {
const o = document.createElement("option");
o.textContent = "(no matching models on Ollama)";
o.disabled = true;
modelSel.appendChild(o);
return;
}
for (const m of models) {
const o = document.createElement("option");
o.value = m; o.textContent = m;
modelSel.appendChild(o);
}
} catch (e) {
meta.innerHTML = `<span class="error">Could not load models: ${e}</span>`;
}
}
function setMeta({ phase, model, seconds, tokens }) {
meta.innerHTML = "";
const indicator = document.createElement("span");
if (phase === "waiting") indicator.className = "spinner";
else if (phase === "streaming") indicator.className = "pulse";
else indicator.style.display = "none";
meta.appendChild(indicator);
const label = document.createElement("span");
const parts = [];
if (phase === "waiting") parts.push("Waking model…");
else if (phase === "streaming") parts.push("Translating…");
parts.push(model);
if (seconds != null) parts.push(`${seconds.toFixed(1)}s`);
if (tokens) parts.push(`${tokens} tok`);
label.textContent = parts.join(" · ");
meta.appendChild(label);
}
async function translate() {
const text = input.value.trim();
if (!text) return;
goBtn.disabled = true;
goBtn.textContent = "Translating…";
output.textContent = "";
output.classList.add("streaming");
const model = modelSel.value;
const t0 = performance.now();
let firstToken = true;
let tokens = 0;
let acc = "";
const ticker = setInterval(() => {
const dt = (performance.now() - t0) / 1000;
setMeta({
phase: firstToken ? "waiting" : "streaming",
model,
seconds: dt,
tokens: firstToken ? 0 : tokens,
});
}, 100);
setMeta({ phase: "waiting", model, seconds: 0 });
try {
const r = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
source: sourceSel.value,
target: targetSel.value,
model,
}),
});
if (!r.ok) throw new Error(await r.text());
const reader = r.body.getReader();
const decoder = new TextDecoder();
let buf = "";
const caret = '<span class="caret"></span>';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let j;
try { j = JSON.parse(line); } catch { continue; }
if (j.error) throw new Error(j.error);
if (j.response) {
if (firstToken) firstToken = false;
acc += j.response;
tokens++;
output.innerHTML =
acc.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c])) + caret;
}
}
}
const dt = (performance.now() - t0) / 1000;
output.textContent = acc;
clearInterval(ticker);
setMeta({ phase: "done", model, seconds: dt, tokens });
refreshLoaded();
} catch (e) {
clearInterval(ticker);
output.innerHTML = `<span class="error">${e.message || e}</span>`;
meta.textContent = "";
} finally {
output.classList.remove("streaming");
goBtn.disabled = false;
goBtn.textContent = "Translate";
}
}
function formatRemaining(expiresAt) {
if (!expiresAt) return null;
const ms = new Date(expiresAt).getTime() - Date.now();
if (!isFinite(ms) || ms <= 0) return null;
const totalSec = Math.floor(ms / 1000);
const mins = Math.floor(totalSec / 60);
const secs = totalSec % 60;
if (mins >= 60) {
const hrs = Math.floor(mins / 60);
const m = mins % 60;
return `${hrs}h${m ? m + "m" : ""}`;
}
if (mins > 0) return `${mins}m${secs ? secs + "s" : ""}`;
return `${secs}s`;
}
async function refreshLoaded() {
let models = [];
try {
const r = await fetch("/api/loaded");
if (r.ok) {
const j = await r.json();
models = j.models || [];
}
} catch {
/* leave previous state if Ollama is briefly unreachable */
return;
}
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
// Update dropdown options. Use textContent (option doesn't render HTML).
for (const opt of modelSel.options) {
if (loadedMap.has(opt.value)) {
opt.textContent = `${opt.value}`;
} else {
opt.textContent = opt.value;
}
}
// Update status line.
if (models.length === 0) {
hotStatus.classList.add("idle");
hotStatusText.textContent = "No model loaded";
return;
}
hotStatus.classList.remove("idle");
const parts = models.map(m => {
const remaining = formatRemaining(m.expires_at);
return remaining ? `${m.name} — unloads in ${remaining}` : m.name;
});
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
}
let typingTimer;
input.addEventListener("input", () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(refreshLoaded, 500);
});
goBtn.addEventListener("click", translate);
input.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
});
fillLangs();
loadModels().then(refreshLoaded);
</script>
</body>
</html>