Files
finrod/static/index.html
João Pedro Battistella Nadas 6d6b52e10e fix: don't show "unloads in" when keep_alive=-1
With the radagast server flipped to OLLAMA_KEEP_ALIVE=-1, /api/ps
returns expires_at as year 4001 (effectively forever), which the
old code rendered as "Hot: gemma2:9b — unloads in 17500000h". Add
a 7-day threshold to formatRemaining(); anything beyond that just
shows the model name with no expiry suffix.
2026-06-09 18:47:52 +02:00

663 lines
22 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;
}
/* Input-pane header: label on the left, "Scan image" button on the right */
.input-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.input-header label { margin-bottom: 0; }
.scan-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
margin: 0;
border-radius: 4px;
cursor: pointer;
letter-spacing: 0.04em;
text-transform: uppercase;
width: auto;
}
.scan-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--fg);
}
.scan-btn:disabled { cursor: progress; opacity: 0.6; }
.scan-status {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.4rem;
min-height: 1em;
}
.scan-status.error { color: var(--error); }
/* CodeMirror editor host — matches .output styling so input & output align */
.cm-editor {
min-height: 180px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 1rem; /* match .output's inherited body size — CM6 defaults are smaller */
}
.cm-editor.cm-focused {
outline: none;
border-color: var(--accent);
}
.cm-scroller { padding: 0.6rem 0.75rem; }
.cm-content { caret-color: var(--accent); }
/* vim's command-line / status hint inherits readable colors */
.cm-vim-panel {
background: var(--panel);
color: var(--fg);
border-top: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</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>
<div class="input-header">
<label for="input">Input</label>
<button type="button" id="scan-btn" class="scan-btn" title="Extract text from a camera shot or image file">Scan image</button>
</div>
<div id="input"></div>
<input type="file" id="scan-input" accept="image/*" capture="environment" hidden>
<div id="scan-status" class="scan-status"></div>
</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 type="module">
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
import { EditorState } from "https://esm.sh/@codemirror/state@6";
import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6";
import { vim, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
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 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");
const scanBtn = document.getElementById("scan-btn");
const scanInput = document.getElementById("scan-input");
const scanStatus = document.getElementById("scan-status");
// --- OCR via Tesseract.js (lazy-loaded on first use from esm.sh) --------
// Tesseract language codes are ISO 639-2 — map from our "From" dropdown.
const TESS_LANGS = {
"English": "eng",
"Portuguese (Brazil)": "por",
"Portuguese (Portugal)": "por",
"Spanish": "spa",
"French": "fra",
"German": "deu",
"Italian": "ita",
"Dutch": "nld",
"Polish": "pol",
"Russian": "rus",
"Japanese": "jpn",
"Korean": "kor",
"Chinese (Simplified)": "chi_sim",
"Chinese (Traditional)": "chi_tra",
"Arabic": "ara",
"Hindi": "hin",
"Turkish": "tur",
"Swedish": "swe",
"Norwegian": "nor",
"Danish": "dan",
"Finnish": "fin",
"Greek": "ell",
"Hebrew": "heb",
"Czech": "ces",
"Hungarian": "hun",
"Romanian": "ron",
"Ukrainian": "ukr",
"Vietnamese": "vie",
"Thai": "tha",
"Indonesian": "ind",
};
// "Auto-detect" — load common European set together; tesseract picks best.
const TESS_AUTO = "eng+por+deu+nld";
function setScanStatus(text, { error = false } = {}) {
scanStatus.textContent = text;
scanStatus.classList.toggle("error", error);
}
async function runOCR(file) {
const src = sourceSel.value;
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
scanBtn.disabled = true;
setScanStatus("Loading OCR engine…");
const t0 = performance.now();
try {
// Lazy import — first scan triggers the ~5 MB JS + language pack
// download; cached by the browser after that.
const { createWorker } = await import("https://esm.sh/tesseract.js@5");
const worker = await createWorker(lang, 1, {
logger: (m) => {
if (m.status === "recognizing text") {
setScanStatus(`OCR ${Math.round(m.progress * 100)}%`);
} else if (m.status) {
setScanStatus(`OCR: ${m.status}`);
}
},
});
const { data: { text } } = await worker.recognize(file);
await worker.terminate();
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text.trim() },
selection: { anchor: 0 },
});
const dt = ((performance.now() - t0) / 1000).toFixed(1);
setScanStatus(`OCR done in ${dt}s (${lang})`);
} catch (e) {
console.error(e);
setScanStatus(`OCR failed: ${e.message || e}`, { error: true });
} finally {
scanBtn.disabled = false;
scanInput.value = ""; // allow re-selecting the same file
}
}
scanBtn.addEventListener("click", () => scanInput.click());
scanInput.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (file) runOCR(file);
});
// --- CodeMirror editor (vim mode on real keyboards only) ----------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
let typingTimer;
const editor = new EditorView({
parent: document.getElementById("input"),
state: EditorState.create({
extensions: [
// vim() MUST be first per @replit/codemirror-vim docs so its keymap
// takes precedence. Skipped on touch devices — modal editing without
// a hardware Esc key on a virtual keyboard is unusable, and IME
// composition fights the vim keymap.
...(isCoarsePointer ? [] : [vim()]),
history(),
keymap.of([
{ key: "Mod-Enter", run: () => { translate(); return true; } },
...defaultKeymap,
...historyKeymap,
]),
EditorView.lineWrapping,
placeholder("Type or paste text to translate…"),
EditorView.updateListener.of(u => {
if (u.docChanged) {
clearTimeout(typingTimer);
typingTimer = setTimeout(refreshLoaded, 500);
}
}),
],
}),
});
const inputText = () => editor.state.doc.toString();
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 = inputText().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;
// Ollama's keep_alive=-1 returns expires_at far in the future (year 4001).
// Any realistic finite keep_alive is hours at most, so treat >7 days
// as "never unloads" and drop the misleading suffix.
if (ms > 7 * 24 * 60 * 60 * 1000) 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(isInitial = false) {
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).
const optionValues = Array.from(modelSel.options, o => o.value);
for (const opt of modelSel.options) {
if (loadedMap.has(opt.value)) {
opt.textContent = `${opt.value}`;
} else {
opt.textContent = opt.value;
}
}
// On the very first refresh after page load, prefer a hot model as the
// dropdown default — skipping disk reload pain. Only on initial load,
// so the user's manual selection isn't yanked away while they type.
if (isInitial) {
const hot = models.find(m => optionValues.includes(m.name));
if (hot) modelSel.value = hot.name;
}
// 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(", ")}`;
}
goBtn.addEventListener("click", translate);
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
// Tab). Two paths kept in sync:
// 1. Document keydown listener — fires when focus is on a select / button
// 2. Vim normal-mode mapping (below) — fires when focus is in the editor
// Together they make Shift-H/L work everywhere, including escaping the editor.
const focusables = [
{ el: sourceSel, focus: () => sourceSel.focus() },
{ el: targetSel, focus: () => targetSel.focus() },
{ el: modelSel, focus: () => modelSel.focus() },
{ el: editor.contentDOM, focus: () => editor.focus() },
{ el: goBtn, focus: () => goBtn.focus() },
];
function moveFocus(delta) {
const ae = document.activeElement;
let idx = focusables.findIndex(f => f.el === ae || f.el.contains?.(ae));
if (idx === -1) {
focusables[delta > 0 ? 0 : focusables.length - 1].focus();
return;
}
const next = focusables[(idx + delta + focusables.length) % focusables.length];
next.focus();
}
document.addEventListener("keydown", (e) => {
if (!e.shiftKey) return;
const k = e.key.toLowerCase();
if (k !== "h" && k !== "l") return;
// Inside the editor, vim's mapCommand handles H/L (see below). Letting
// the event bubble here would double-jump.
if (editor.dom.contains(document.activeElement)) return;
e.preventDefault();
moveFocus(k === "l" ? 1 : -1);
});
// Override vim's H/L (normally "viewport top/bottom") so they exit the
// editor to the previous/next control, matching the Shift-H/L contract
// the document handler uses elsewhere.
if (!isCoarsePointer) {
Vim.defineAction("finrodFocusPrev", () => moveFocus(-1));
Vim.defineAction("finrodFocusNext", () => moveFocus(1));
Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" });
Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" });
}
fillLangs();
loadModels().then(() => refreshLoaded(true));
</script>
</body>
</html>