- .cm-editor pinned to font-size 1rem so the input matches the output pane (CodeMirror 6 defaults smaller) - refreshLoaded(isInitial=true) on first load picks the hot model as the dropdown default — avoids the 5-15s disk reload on the first translation. Subsequent refreshes don't override the user's manual selection. - document-level Shift-H / Shift-L cycle focus across controls (source → target → model → editor → button), suppressed when focus is inside CodeMirror so vim's viewport H/L still work.
513 lines
17 KiB
HTML
513 lines
17 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;
|
|
}
|
|
|
|
/* 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>
|
|
<label for="input">Input</label>
|
|
<div id="input"></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 } 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");
|
|
|
|
// --- 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 => ({ "&": "&", "<": "<", ">": ">" }[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(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 navigate focus across controls (vim-style Shift-Tab /
|
|
// Tab). Skipped when focus is inside the CodeMirror editor — there H/L
|
|
// are vim's viewport motions and must reach the editor untouched.
|
|
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() },
|
|
];
|
|
document.addEventListener("keydown", (e) => {
|
|
if (!e.shiftKey) return;
|
|
const k = e.key.toLowerCase();
|
|
if (k !== "h" && k !== "l") return;
|
|
if (editor.dom.contains(document.activeElement)) return; // vim owns H/L in the editor
|
|
e.preventDefault();
|
|
const ae = document.activeElement;
|
|
let idx = focusables.findIndex(f => f.el === ae);
|
|
if (idx === -1) {
|
|
focusables[k === "l" ? 0 : focusables.length - 1].focus();
|
|
return;
|
|
}
|
|
const delta = k === "l" ? 1 : -1;
|
|
const next = focusables[(idx + delta + focusables.length) % focusables.length];
|
|
next.focus();
|
|
});
|
|
|
|
fillLangs();
|
|
loadModels().then(() => refreshLoaded(true));
|
|
</script>
|
|
</body>
|
|
</html>
|