feat: machine translation through a local translator running in the browser's machine
CI / frontend (push) Failing after 17s
CI / backend (push) Successful in 47s

Translator panel in the translation editor and a /translator setup page. The browser talks to
local-translator directly; the server only receives lines marked as machine.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 18:59:27 +03:00
1 parent a1f5ef8e2d
commit e8d6bf4646
10 files changed
+1039 -4

No files matched your search

@@ -0,0 +1,292 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { api, type Game, type Version } from "../api";
import type { GlossaryTerm, Overview } from "../pages/translation-types";
import { ErrorNote } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import {
TranslatorClient, languageName, normalizeUrl, savedUrl, type Link as Connection,
} from "./client";
import { runTranslation, type Progress, type Source } from "./run";
import "./translator.css";
const PAGE = 40;
const PREFS_KEY = "pg.translator.prefs";
interface Prefs {
provider: string;
model: string;
batch: number;
outdated: boolean;
strings: boolean;
}
const DEFAULT_PREFS: Prefs = { provider: "", model: "", batch: 10, outdated: false, strings: true };
function loadPrefs(): Prefs {
try {
return { ...DEFAULT_PREFS, ...JSON.parse(localStorage.getItem(PREFS_KEY) ?? "{}") };
} catch {
return DEFAULT_PREFS;
}
}
function instructionsKey(gameId: number) {
return `pg.translator.instructions.${gameId}`;
}
function readText(key: string): string {
try {
return localStorage.getItem(key) ?? "";
} catch {
return "";
}
}
type Phase = { kind: "lines" | "strings"; state: "untranslated" | "outdated" };
/** Machine translation of one language through the translator running on this computer. */
export function MachineTranslate({ versionId, version, lang, ov, onClose, onChanged }: {
versionId: string; version: Version; lang: string; ov: Overview; onClose: () => void; onChanged: () => void;
}) {
const client = useMemo(() => new TranslatorClient(normalizeUrl(savedUrl())), []);
const [link, setLink] = useState<Connection | "checking">("checking");
const [providers, setProviders] = useState<string[]>([]);
const [models, setModels] = useState<string[] | null>(null);
const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
const [instructions, setInstructions] = useState(() => readText(instructionsKey(version.game_id)));
const [glossary, setGlossary] = useState<GlossaryTerm[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [todo, setTodo] = useState<number | null>(null);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<Progress | null>(null);
const [error, setError] = useState("");
const [summary, setSummary] = useState("");
const abort = useRef<AbortController | null>(null);
const setPref = <K extends keyof Prefs>(key: K, value: Prefs[K]) => {
setPrefs((p) => {
const next = { ...p, [key]: value };
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(next));
} catch {
/* the choice just is not remembered */
}
return next;
});
};
const connect = useCallback(async () => {
setLink("checking");
const result = await client.diagnose();
setLink(result);
if (result.state !== "ok") return;
try {
const list = Object.keys(await client.providers());
setProviders(list);
setPrefs((p) => (list.includes(p.provider) ? p : { ...p, provider: list[0] ?? "" }));
} catch {
setProviders([]);
}
}, [client]);
useEffect(() => void connect(), [connect]);
useEffect(() => {
api.get<Game>(`/api/games/${version.game_id}`).then(setGame).catch(() => {});
api.get<{ glossary: GlossaryTerm[] }>(`/api/versions/${versionId}/i18n/${lang}/meta`)
.then((m) => setGlossary(m.glossary ?? [])).catch(() => {});
}, [version.game_id, versionId, lang]);
useEffect(() => {
if (!prefs.provider || link === "checking" || link.state !== "ok") return;
let stale = false;
setModels(null);
client.models(prefs.provider).then((m) => !stale && setModels(m)).catch(() => !stale && setModels([]));
return () => {
stale = true;
};
}, [client, link, prefs.provider]);
const phases = useMemo<Phase[]>(() => {
const states: Phase["state"][] = prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"];
const kinds: Phase["kind"][] = prefs.strings ? ["lines", "strings"] : ["lines"];
return kinds.flatMap((kind) => states.map((state) => ({ kind, state })));
}, [prefs.outdated, prefs.strings]);
const entriesUrl = useCallback((p: Phase, offset: number, limit: number) =>
`/api/versions/${versionId}/i18n/${lang}/entries?${new URLSearchParams({
kind: p.kind, state: p.state, offset: String(offset), limit: String(limit) })}`, [versionId, lang]);
// how much there is to translate with the current choices
useEffect(() => {
if (running) return;
let stale = false;
Promise.all(phases.map((p) => api.get<{ total: number }>(entriesUrl(p, 0, 1))))
.then((r) => !stale && setTodo(r.reduce((n, x) => n + x.total, 0))).catch(() => !stale && setTodo(null));
return () => {
stale = true;
};
}, [phases, entriesUrl, running, summary]);
useEffect(() => () => abort.current?.abort(), []);
const start = async () => {
setError("");
setSummary("");
const ctl = new AbortController();
abort.current = ctl;
setRunning(true);
const base = { total: 0, done: 0, failed: 0 };
let last: Progress = { ...base, lastError: "", model: null };
try {
try {
localStorage.setItem(instructionsKey(version.game_id), instructions);
} catch {
/* not remembered */
}
const title = game?.title ?? version.name;
const projectId = await client.saveProject({
name: `PGames: ${title} (${ov.sourceLang ?? "?"} → ${lang})`,
source_language: languageName(ov.sourceLang ?? "en", ov.knownLanguages),
target_language: languageName(lang, ov.knownLanguages),
context: [`Game: ${title}.`, game?.description, game?.tags?.length ? `Tags: ${game.tags.join(", ")}.` : ""]
.filter(Boolean).join(" ").slice(0, 4000),
instructions: instructions.trim(),
glossary: Object.fromEntries(glossary.filter((g) => g.src.trim() && g.t.trim()).map((g) => [g.src.trim(), g.t.trim()])),
provider: prefs.provider || "lmstudio",
model: prefs.model || null,
});
for (const phase of phases) {
const outcome = await runTranslation({
client, projectId, batchSize: prefs.batch, pageSize: PAGE, signal: ctl.signal,
fetchPage: (skip, limit) => api.get<{ total: number; items: (Source & { translation?: string })[] }>(
entriesUrl(phase, skip, limit)),
save: async (results) => {
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
await api.put(`/api/versions/${versionId}/i18n/${lang}`,
phase.kind === "strings" ? { strings: changes } : { lines: changes });
},
onProgress: (p) => {
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
setProgress(last);
},
});
Object.assign(base, { total: last.total, done: last.done, failed: last.failed });
if (outcome === "stopped") break;
}
setSummary(ctl.signal.aborted ? `Остановлено. Переведено строк: ${last.done}.`
: `Готово. Переведено строк: ${last.done}${last.failed ? `, не удалось: ${last.failed}` : ""}.`);
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
abort.current = null;
onChanged();
}
};
const stop = () => abort.current?.abort();
const health = link !== "checking" && link.state === "ok" ? link.health : null;
const ready = health !== null;
const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0;
return (
<section className="card mt-panel" aria-label="Машинный перевод">
<div className="mt-head">
<h2>Машинный перевод</h2>
<button className="btn btn-sm btn-ghost" onClick={onClose} disabled={running} aria-label="Закрыть"><Icon name="x" size={16} /></button>
</div>
<p className="muted small">
Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой
«Машинный». <Link to="/translator">Как установить и настроить</Link>
</p>
{link === "checking" && <p className="muted small">Ищу переводчик на {client.base}…</p>}
{link !== "checking" && link.state === "offline" && (
<ErrorNote>Переводчик не отвечает на {client.base}. Запустите его (<Link to="/translator">инструкция</Link>)
и нажмите «Проверить снова».</ErrorNote>
)}
{link !== "checking" && link.state === "blocked" && (
<ErrorNote>Переводчик запущен, но браузер не пускает этот сайт к нему. Добавьте <b>{location.origin}</b> в
allowed_origins в config.toml и перезапустите переводчик (<Link to="/translator">подробнее</Link>).</ErrorNote>
)}
{link !== "checking" && link.state === "old" && (
<ErrorNote>Версия переводчика слишком старая: обновите репозиторий local-translator и перезапустите его.</ErrorNote>
)}
{link !== "checking" && link.state !== "ok" && (
<div className="actions"><button className="btn btn-sm" onClick={() => void connect()}><Icon name="retry" size={16} />Проверить снова</button></div>
)}
{health?.startup_errors[prefs.provider] && <ErrorNote>{health.startup_errors[prefs.provider]}</ErrorNote>}
{ready && (
<>
<div className="form-row">
<label className="field">
<span>Источник</span>
<select value={prefs.provider} disabled={running} onChange={(e) => { setPref("provider", e.target.value); setPref("model", ""); }}>
{providers.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
<label className="field">
<span>Модель</span>
<select value={prefs.model} disabled={running} onChange={(e) => setPref("model", e.target.value)}>
<option value="">Как в настройках переводчика</option>
{(models ?? []).map((m) => <option key={m} value={m}>{m}</option>)}
{prefs.model && !(models ?? []).includes(prefs.model) && <option value={prefs.model}>{prefs.model}</option>}
</select>
{models !== null && !models.length && (
<small className="hint">Список моделей пуст: запустите сервер в LM Studio и загрузите модель.</small>
)}
</label>
<label className="field">
<span>Строк за запрос</span>
<input type="number" min={1} max={50} value={prefs.batch} disabled={running}
onChange={(e) => setPref("batch", Math.max(1, Math.min(50, Number(e.target.value) || 10)))} />
<small className="hint">Меньше — надёжнее для слабых моделей.</small>
</label>
</div>
<label className="field">
<span>Пожелания к переводу</span>
<textarea rows={2} value={instructions} disabled={running} maxLength={5000}
placeholder="Например: обращение на «ты», сохраняй грубость оригинала. Запоминается для этой игры."
onChange={(e) => setInstructions(e.target.value)} />
<small className="hint">Имена и термины берутся из вкладки «Глоссарий» ({glossary.filter((g) => g.t.trim()).length}).</small>
</label>
<div className="mt-options">
<label className="check"><input type="checkbox" checked={prefs.strings} disabled={running}
onChange={(e) => setPref("strings", e.target.checked)} /> Имена, меню и интерфейс</label>
<label className="check"><input type="checkbox" checked={prefs.outdated} disabled={running}
onChange={(e) => setPref("outdated", e.target.checked)} /> Обновить устаревшие переводы</label>
</div>
<div className="actions">
{running ? (
<button className="btn" onClick={stop}><Icon name="x" size={18} />Остановить</button>
) : (
<button className="btn btn-primary" onClick={() => void start()} disabled={!todo || !prefs.provider}>
<Icon name="play" size={18} />Перевести{todo ? ` · ${todo}` : ""}
</button>
)}
{todo === 0 && !running && <span className="muted small">Всё уже переведено.</span>}
</div>
</>
)}
{progress && (running || summary) && (
<div className="mt-progress" aria-live="polite">
<div className="progress"><div style={{ width: `${percent}%` }} /></div>
<span className="muted small">
{progress.done} из {progress.total}{progress.failed ? ` · ошибок ${progress.failed}` : ""}
{progress.model ? ` · ${progress.model}` : ""}
</span>
</div>
)}
{progress?.lastError && progress.failed > 0 && <p className="muted small">Последняя ошибка: {progress.lastError}</p>}
{summary && <div className="note note-ok"><Icon name="check" size={18} /><span>{summary}</span></div>}
{error && <ErrorNote>{error}</ErrorNote>}
</section>
);
}