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

+5
View File
@@ -11,6 +11,7 @@ import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles";
import { Saves } from "./pages/Saves";
import { TranslationEditor } from "./pages/TranslationEditor";
import { TranslatorSetup } from "./translator/TranslatorSetup";
import { Users } from "./pages/Users";
import { useTheme } from "./theme";
import { Icon } from "./ui/Icon";
@@ -62,6 +63,9 @@ function UserMenu() {
</div>
<NavLink to="/account" className="menu-item" role="menuitem"><Icon name="key" size={18} />Аккаунт и пароль</NavLink>
<NavLink to="/saves" className="menu-item" role="menuitem"><Icon name="download" size={18} />Сохранения</NavLink>
{can("translations.edit") && (
<NavLink to="/translator" className="menu-item" role="menuitem"><Icon name="globe" size={18} />Локальный переводчик</NavLink>
)}
{(can("users.manage") || can("roles.manage")) && <div className="menu-section">Администрирование</div>}
{can("users.manage") && (
<NavLink to="/admin/users" className="menu-item" role="menuitem"><Icon name="users" size={18} />Пользователи</NavLink>
@@ -115,6 +119,7 @@ export function App() {
<Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
<Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} />
<Route path="/translator" element={<RequirePerm perm="translations.edit"><TranslatorSetup /></RequirePerm>} />
<Route path="/versions/:versionId/edit" element={<RequirePerm perm="games.play"><ModEditor /></RequirePerm>} />
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
+14
View File
@@ -4,6 +4,7 @@ import { api, uploadRaw, type Version } from "../api";
import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
import { MachineTranslate } from "../translator/MachineTranslate";
import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts";
import {
FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE,
@@ -19,6 +20,7 @@ export function TranslationEditor() {
const [version, setVersion] = useState<Version | null>(null);
const [ov, setOv] = useState<Overview | null>(null);
const [error, setError] = useState("");
const [machine, setMachine] = useState(false);
const lang = params.get("lang") ?? "";
const tab = (params.get("tab") as Tab) ?? "lines";
@@ -181,12 +183,24 @@ export function TranslationEditor() {
onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void uploadFont(f); }} />
</label>
)}
{ov.editable && (
<button className="btn btn-sm" aria-expanded={machine} onClick={() => setMachine((m) => !m)}
title="Перевод нейросетью, которая работает на вашем компьютере">
<Icon name="globe" size={16} />Машинный перевод
</button>
)}
<Link className="btn btn-sm btn-ghost" to={`/play/${versionId}?lang=${lang}`}><Icon name="play" size={16} />Играть на этом языке</Link>
</div>
</>
)}
</div>
{machine && ov.editable && stats && (
<MachineTranslate key={lang} versionId={versionId!} version={version} lang={lang} ov={ov}
onClose={() => setMachine(false)}
onChanged={() => void loadOverview().then(() => setParam("r", String(Date.now())))} />
)}
{!ov.languages.length ? (
<EmptyState icon="globe" title="Переводов пока нет"
text={ov.editable ? "Добавьте язык, чтобы начать перевод." : "У этой версии нет переводов."}
@@ -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>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { PageHeader } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
import {
DEFAULT_URL, TranslatorClient, normalizeUrl, saveUrl, savedUrl, type Link,
} from "./client";
import "./translator.css";
const REPO = "https://gitea.hsrv.site/mrleo1nid/local-translator";
function Code({ children, copy }: { children: string; copy?: boolean }) {
const ui = useUi();
return (
<div className="tl-code">
<pre><code>{children}</code></pre>
{copy && (
<button className="btn btn-sm btn-ghost" onClick={() => {
navigator.clipboard.writeText(children).then(() => ui.toast("Скопировано", "ok"), () => ui.toast("Не удалось скопировать", "error"));
}}><Icon name="copy" size={14} />Копировать</button>
)}
</div>
);
}
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
<section className="card tl-step">
<h2><span className="tl-num">{n}</span>{title}</h2>
{children}
</section>
);
}
type Check = { ok: boolean | null; text: string; hint?: ReactNode };
/** What works and what does not, from the browser's own point of view. */
async function inspect(client: TranslatorClient): Promise<{ checks: Check[]; link: Link }> {
const link = await client.diagnose();
const checks: Check[] = [];
if (link.state === "offline") {
return { link, checks: [{ ok: false, text: "Переводчик не найден", hint: <>Запустите его (шаг 2) и проверьте адрес.</> }] };
}
if (link.state === "blocked") {
return { link, checks: [
{ ok: true, text: "Переводчик запущен" },
{ ok: false, text: "Этот сайт не разрешён", hint: <>Добавьте адрес сайта в <code>allowed_origins</code> (шаг 3) и перезапустите переводчик. Если уже добавили — проверьте, что адрес совпадает до символа, включая https и порт.</> },
] };
}
checks.push({ ok: true, text: "Переводчик запущен и отвечает этому сайту" });
if (link.state === "old") {
checks.push({ ok: false, text: "Версия переводчика устарела", hint: <>Выполните <code>git pull</code> в папке переводчика и перезапустите его.</> });
return { link, checks };
}
checks.push({ ok: true, text: `Версия API ${link.health.api_version}` });
const errors = Object.entries(link.health.startup_errors);
for (const [name, message] of errors) checks.push({ ok: false, text: `Запуск «${name}» не удался`, hint: message });
try {
const providers = Object.keys(await client.providers());
const first = providers.includes("lmstudio") ? "lmstudio" : providers[0];
checks.push({ ok: providers.length > 0, text: `Источники перевода: ${providers.join(", ") || "нет"}` });
if (first) {
try {
const models = await client.models(first);
checks.push(models.length
? { ok: true, text: `Модели в «${first}»: ${models.join(", ")}` }
: { ok: false, text: `В «${first}» нет загруженных моделей`, hint: <>В LM Studio загрузите модель и включите сервер (Developer → Start Server).</> });
} catch {
checks.push({ ok: false, text: `Источник «${first}» недоступен`, hint: <>Запустите LM Studio, включите Developer → Start Server и загрузите модель.</> });
}
}
} catch {
checks.push({ ok: false, text: "Не удалось получить список источников" });
}
return { link, checks };
}
export function TranslatorSetup() {
const [address, setAddress] = useState(savedUrl);
const [checks, setChecks] = useState<Check[] | null>(null);
const [busy, setBusy] = useState(false);
const origin = location.origin;
const test = useCallback(async (value: string) => {
setBusy(true);
try {
const url = normalizeUrl(value);
saveUrl(url);
setChecks((await inspect(new TranslatorClient(url))).checks);
} finally {
setBusy(false);
}
}, []);
useEffect(() => void test(savedUrl()), [test]);
const config = useMemo(() => `allowed_origins = ["${origin}"]`, [origin]);
return (
<div className="page medium tl-page">
<PageHeader title="Локальный переводчик"
subtitle="Машинный перевод игр нейросетью на вашем компьютере: быстро, бесплатно и без отправки текста в облако." />
<div className="card">
<h2>Как это устроено</h2>
<p>
Переводит программа <b>local-translator</b>, которая запускается у вас на компьютере и использует локальную
модель через LM Studio. Страница редактора перевода в браузере обращается к ней напрямую, а библиотека
получает только готовые строки. Сервер библиотеки не знает ни о переводчике, ни о модели.
</p>
<div className="tl-flow" aria-hidden="true">
<span>Браузер с этим сайтом</span><i>→</i><span>local-translator<br /><small>127.0.0.1:8787</small></span>
<i>→</i><span>LM Studio и модель<br /><small>127.0.0.1:1234</small></span>
</div>
<p className="muted small">
Перевод идёт построчно и сохраняется после каждой пачки с пометкой «Машинный»: его можно остановить в любой момент,
а потом проверить и поправить вручную. Работает только при открытой странице редактора.
</p>
</div>
<Step n={1} title="Установите LM Studio и скачайте модель">
<p>
Скачайте <a href="https://lmstudio.ai" target="_blank" rel="noreferrer">LM Studio</a>, найдите модель
в поиске и загрузите её. Затем откройте вкладку <b>Developer</b> и нажмите <b>Start Server</b> (порт 1234).
</p>
<table className="table tl-models">
<thead><tr><th>Видеопамять</th><th>Что взять</th><th>Заметки</th></tr></thead>
<tbody>
<tr><td>6–8 ГБ</td><td><b>HY-MT2-7B, Q4_K_M</b> (4,6 ГБ)</td><td>Минимум для комфортной работы; лучше пачки по 5 строк.</td></tr>
<tr><td>12 ГБ</td><td><b>HY-MT2-7B, Q6_K</b> (6,2 ГБ)</td><td>Разумный выбор по умолчанию для RTX 3060.</td></tr>
<tr><td>16 ГБ и больше</td><td><b>HY-MT2-7B, Q8_0</b> (8 ГБ)</td><td>Почти без потерь качества, остаётся запас на контекст.</td></tr>
<tr><td>Без видеокарты</td><td>та же модель, Q4_K_M</td><td>Работает на процессоре, но в разы медленнее.</td></tr>
</tbody>
</table>
<p className="muted small">
HY-MT2 — специализированная модель перевода от Tencent, переводчик настроен под неё. Размеры — это только
веса, контексту нужна дополнительная память. Это отправная точка, а не измерение на вашем железе:
переведите 20–30 строк игры и сравните два варианта. Если игра откровенная, проверьте, что модель не
смягчает и не отказывается переводить; тогда попробуйте другую (например, Qwen3-8B abliterated).
Нужен перевод из облака — подойдёт OpenRouter, но тогда текст уходит на сторону.
</p>
</Step>
<Step n={2} title="Запустите переводчик">
<p>Нужен Python 3.11 или новее.</p>
<p><b>Windows:</b></p>
<Code copy>{`git clone ${REPO}\ncd local-translator\nscripts\\windows\\start.bat`}</Code>
<p><b>Linux и macOS:</b></p>
<Code copy>{`git clone ${REPO}\ncd local-translator\nbash start.sh`}</Code>
<p className="muted small">
При первом запуске создаётся окружение и копируется <code>config.toml</code>. Окно должно оставаться открытым,
пока вы переводите. Если у вас несколько моделей, впишите точное имя в <code>providers.lmstudio.model</code>
или выбирайте модель прямо в панели перевода.
</p>
</Step>
<Step n={3} title="Разрешите этому сайту обращаться к переводчику">
<p>
По умолчанию переводчик закрыт для браузера, иначе любой сайт мог бы пользоваться вашей моделью. Откройте
<code> config.toml</code>, найдите строку <code>allowed_origins</code> и впишите адрес этой библиотеки:
</p>
<Code copy>{config}</Code>
<p className="muted small">
Адрес берётся из вашей адресной строки, без пути в конце. После правки перезапустите переводчик.
Для разработки можно добавить несколько адресов через запятую.
</p>
</Step>
<Step n={4} title="Проверьте связь">
<form className="form-row" onSubmit={(e) => { e.preventDefault(); void test(address); }}>
<label className="field">
<span>Адрес переводчика</span>
<input value={address} onChange={(e) => setAddress(e.target.value)} spellCheck={false} />
{address !== DEFAULT_URL && <small className="hint">Стандартный адрес: {DEFAULT_URL}</small>}
</label>
<button className="btn btn-primary" disabled={busy}><Icon name="retry" size={18} />{busy ? "Проверяю…" : "Проверить"}</button>
</form>
{checks && (
<ul className="tl-checks">
{checks.map((c, i) => (
<li key={i} className={c.ok ? "ok" : "bad"}>
<Icon name={c.ok ? "check" : "alert"} size={18} />
<div><span>{c.text}</span>{c.hint && <p className="muted small">{c.hint}</p>}</div>
</li>
))}
</ul>
)}
{checks && checks.every((c) => c.ok) && (
<div className="note note-ok"><Icon name="check" size={18} /><span>Всё готово. Откройте редактор перевода игры и нажмите «Машинный перевод».</span></div>
)}
</Step>
<div className="card">
<h2>Браузеры и неполадки</h2>
<ul className="tl-list">
<li><b>Chrome и Edge</b> при первом обращении спросят разрешение на доступ к устройствам в локальной сети.
Нажмите «Разрешить», иначе связи не будет.</li>
<li><b>Firefox</b> обычно пропускает обращения к 127.0.0.1 без вопросов.</li>
<li><b>Safari</b> может запрещать страницам по https обращаться к локальному http-адресу. Используйте другой браузер.</li>
<li>Блокировщики рекламы и расширения безопасности иногда мешают; попробуйте отключить их для этого сайта.</li>
<li>Перевод остановился или медленный? Уменьшите «строк за запрос» в панели или возьмите квантовку поменьше.
Подробности пишутся в окно переводчика.</li>
<li>Тексты со вставками вида <code>{"{name}"}</code>, <code>[var]</code> и тегами переводчик защищает сам и не принимает
ответ, где они повреждены: такая строка остаётся непереведённой, и её видно по счётчику ошибок.</li>
</ul>
</div>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
// Client for the "Local Translator" service that runs on the user's own machine.
// The browser talks to it directly; the library server never learns about it and only
// receives the finished translations through the ordinary translation API.
export const DEFAULT_URL = "http://127.0.0.1:8787";
const URL_KEY = "pg.translator.url";
/** The oldest translator API this client works with (cancel + CORS arrived with 2). */
export const MIN_API = 2;
export function savedUrl(): string {
try {
return localStorage.getItem(URL_KEY) || DEFAULT_URL;
} catch {
return DEFAULT_URL;
}
}
export function saveUrl(url: string): void {
try {
if (url && url !== DEFAULT_URL) localStorage.setItem(URL_KEY, url);
else localStorage.removeItem(URL_KEY);
} catch {
/* private mode: the default is used */
}
}
export function normalizeUrl(raw: string): string {
const text = raw.trim().replace(/\/+$/, "");
return /^https?:\/\//i.test(text) ? text : `http://${text}`;
}
export interface Health {
status: string;
api_version?: number;
worker_running: boolean;
startup_errors: Record<string, string>;
}
export type Link =
| { state: "ok"; health: Health }
/** nothing answers at that address */
| { state: "offline" }
/** something answers, but the browser may not read it: this site is not in allowed_origins */
| { state: "blocked" }
/** the service is older than this client needs */
| { state: "old"; health: Health };
export interface Item {
id: string;
source: string;
translation: string | null;
status: "pending" | "completed" | "failed";
error: string | null;
}
export interface Job {
id: string;
status: "queued" | "running" | "completed" | "completed_with_errors" | "failed" | "cancelled";
model: string | null;
error: string | null;
items: Item[];
progress: { pending: number; completed: number; failed: number };
}
export interface ProjectSettings {
name: string;
source_language: string;
target_language: string;
context: string;
instructions: string;
glossary: Record<string, string>;
provider: string;
model: string | null;
}
export class TranslatorError extends Error {
constructor(message: string, public status = 0) {
super(message);
}
}
type Fetch = typeof fetch;
export class TranslatorClient {
constructor(public readonly base: string, private readonly fetcher: Fetch = (...a) => fetch(...a)) {}
private async call<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
let r: Response;
try {
r = await this.fetcher(this.base + path, {
method, signal,
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (e) {
if ((e as Error).name === "AbortError") throw e;
throw new TranslatorError("Переводчик не отвечает");
}
if (!r.ok) {
let detail = r.statusText;
try {
const data = await r.json();
detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
} catch {
/* not json */
}
throw new TranslatorError(detail || `Ошибка ${r.status}`, r.status);
}
return r.json();
}
/** Tells "not running" apart from "running, but this site is not allowed to read it". */
async diagnose(): Promise<Link> {
try {
const health = await this.call<Health>("GET", "/health");
return (health.api_version ?? 1) >= MIN_API ? { state: "ok", health } : { state: "old", health };
} catch {
try {
// An opaque answer still proves that a server is listening there.
await this.fetcher(this.base + "/health", { mode: "no-cors" });
return { state: "blocked" };
} catch {
return { state: "offline" };
}
}
}
providers(): Promise<Record<string, { base_url: string; model: string }>> {
return this.call("GET", "/providers");
}
async models(provider: string): Promise<string[]> {
const r = await this.call<{ data?: { id?: unknown }[] }>("GET", `/providers/${encodeURIComponent(provider)}/models`);
return (r.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === "string" && !!id);
}
/** One project per game and language pair, found by name so it survives cleared site data. */
async saveProject(settings: ProjectSettings): Promise<string> {
const all = await this.call<{ id: string; name: string }[]>("GET", "/projects");
const found = all.find((p) => p.name === settings.name);
const body = { ...settings, disabled_rules: [], protection_rules: [] };
if (found) {
await this.call("PUT", `/projects/${found.id}`, body);
return found.id;
}
return (await this.call<{ id: string }>("POST", "/projects", body)).id;
}
createJob(projectId: string, items: { id: string; text: string }[], batchSize: number): Promise<Job> {
return this.call("POST", `/projects/${projectId}/jobs`, {
items, batch_size: batchSize, max_batch_chars: 12000, retries: 2, use_cache: true,
});
}
job(id: string, signal?: AbortSignal): Promise<Job> {
return this.call("GET", `/jobs/${id}`, undefined, signal);
}
cancel(id: string): Promise<Job> {
return this.call("POST", `/jobs/${id}/cancel`, {});
}
}
/** English name of a BCP-47 code: the translator prompts the model with it. */
export function languageName(code: string, known?: Record<string, string>): string {
try {
const name = new Intl.DisplayNames(["en"], { type: "language" }).of(code);
if (name && name !== code) return name;
} catch {
/* invalid code */
}
return known?.[code] ?? code;
}
+103
View File
@@ -0,0 +1,103 @@
// Machine translation of a language: take untranslated lines page by page, send each page to the
// local translator, save what came back. Saving after every page means a stopped or crashed run
// loses at most one page, and the next run continues where this one ended.
import type { Job, TranslatorClient } from "./client";
export interface Source {
key: string;
source: string;
}
export interface RunOptions {
client: Pick<TranslatorClient, "createJob" | "job" | "cancel">;
projectId: string;
batchSize: number;
/** lines per translator job; each job is saved on its own */
pageSize: number;
signal: AbortSignal;
/** the next `pageSize` items still to translate, skipping the first `skip` (the ones that failed) */
fetchPage: (skip: number, limit: number) => Promise<{ total: number; items: Source[] }>;
save: (results: { key: string; text: string }[]) => Promise<void>;
onProgress: (p: Progress) => void;
pollMs?: number;
}
export interface Progress {
total: number;
done: number;
failed: number;
/** the latest reason a line was not translated */
lastError: string;
model: string | null;
}
export type Outcome = "finished" | "stopped";
const sleep = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms);
signal.addEventListener("abort", () => {
clearTimeout(t);
resolve();
}, { once: true });
});
async function waitForJob(opts: RunOptions, first: Job): Promise<Job> {
let job = first;
let errors = 0;
// cancelling returns the job as it stands, with whatever was finished before the stop
const stop = () => opts.client.cancel(job.id).catch(() => job);
while (job.status === "queued" || job.status === "running") {
if (opts.signal.aborted) return stop();
await sleep(opts.pollMs ?? 700, opts.signal);
if (opts.signal.aborted) return stop();
try {
job = await opts.client.job(job.id, opts.signal);
errors = 0;
} catch (e) {
if (opts.signal.aborted) return stop();
if (++errors >= 4) throw e;
}
}
return job;
}
/** Throws when the translator becomes unreachable or a save fails. */
export async function runTranslation(opts: RunOptions): Promise<Outcome> {
const progress: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
const emit = () => opts.onProgress({ ...progress });
let skip = 0;
while (!opts.signal.aborted) {
const page = await opts.fetchPage(skip, opts.pageSize);
if (opts.signal.aborted || !page.items.length) break;
// `page.total` still counts the lines that failed earlier, so it is the whole remaining work
progress.total = progress.done + page.total;
const items = page.items.filter((it) => it.source.trim());
// lines that stay untranslated keep matching the filter, so the next page starts after them
skip += page.items.length - items.length;
if (!items.length) continue;
let job = await opts.client.createJob(
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
job = await waitForJob(opts, job);
progress.model = job.model ?? progress.model;
const ready: { key: string; text: string }[] = [];
for (const it of job.items) {
const src = items[Number(it.id)];
if (it.status === "completed" && it.translation?.trim() && src) ready.push({ key: src.key, text: it.translation });
else if (job.status !== "cancelled") progress.lastError = it.error ?? "Модель вернула пустой перевод";
}
if (ready.length) await opts.save(ready);
progress.done += ready.length;
if (job.status !== "cancelled") progress.failed += items.length - ready.length;
skip += items.length - ready.length;
emit();
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
if (job.status === "cancelled") break;
}
emit();
return opts.signal.aborted ? "stopped" : "finished";
}
+59
View File
@@ -0,0 +1,59 @@
/* Local translator: setup page and the machine translation panel */
.tl-page { display: flex; flex-direction: column; gap: var(--space-4); }
.tl-step h2 { display: flex; align-items: center; gap: var(--space-2); }
.tl-num {
display: inline-grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.95rem;
flex-shrink: 0;
}
.tl-step p, .tl-page .card > p { margin: 0 0 var(--space-3); line-height: 1.55; }
.tl-flow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin: var(--space-3) 0;
}
.tl-flow span {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface-2);
text-align: center;
line-height: 1.4;
}
.tl-flow small { color: var(--muted); }
.tl-flow i { font-style: normal; color: var(--muted); font-size: 1.3rem; }
.tl-code {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin: 0 0 var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
}
.tl-code pre { flex: 1; margin: 0; overflow-x: auto; }
.tl-models { margin-bottom: var(--space-3); }
.tl-checks { list-style: none; margin: var(--space-3) 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.tl-checks li { display: flex; gap: var(--space-2); align-items: flex-start; }
.tl-checks li svg { flex-shrink: 0; margin-top: 0.15rem; }
.tl-checks li.ok svg { color: var(--ok); }
.tl-checks li.bad svg { color: var(--danger); }
.tl-checks p { margin: 0.2rem 0 0; }
.tl-list { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: var(--space-2); line-height: 1.55; }
.mt-panel { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-4); }
.mt-head { display: flex; align-items: center; justify-content: space-between; }
.mt-head h2 { margin: 0; }
.mt-panel p { margin: 0; }
.mt-options { display: flex; gap: var(--space-4); flex-wrap: wrap; }
.check { display: inline-flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.mt-progress { display: flex; flex-direction: column; gap: 0.35rem; }
+140
View File
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
const load = async (file) => {
const out = await build({ entryPoints: [fileURLToPath(new URL(`../src/translator/${file}`, import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString("base64")}`);
};
const { runTranslation } = await load("run.ts");
const { TranslatorClient, normalizeUrl } = await load("client.ts");
// A translator that finishes jobs at once; `broken` lines come back failed.
function fakeTranslator({ broken = [] } = {}) {
const jobs = new Map();
const created = [];
return {
created, jobs,
async createJob(_project, items) {
const job = { id: `j${jobs.size}`, status: "completed_with_errors", model: "m", error: null,
items: items.map((it) => broken.includes(it.text)
? { id: it.id, source: it.text, translation: null, status: "failed", error: "bad" }
: { id: it.id, source: it.text, translation: `RU ${it.text}`, status: "completed", error: null }) };
jobs.set(job.id, job);
created.push(items);
return job;
},
async job(id) { return jobs.get(id); },
async cancel(id) { return { ...jobs.get(id), status: "cancelled" }; },
};
}
// The "server": untranslated lines match the filter until they are saved.
function fakeServer(texts) {
const rows = texts.map((source, i) => ({ key: `k${i}`, source }));
const saved = {};
return {
saved,
fetchPage: async (skip, limit) => {
const open = rows.filter((r) => !(r.key in saved));
return { total: open.length, items: open.slice(skip, skip + limit) };
},
save: async (results) => { for (const r of results) saved[r.key] = r.text; },
};
}
const run = (client, server, extra = {}) => runTranslation({
client, projectId: "p", batchSize: 10, pageSize: 2, signal: new AbortController().signal,
...server, onProgress: () => {}, pollMs: 1, ...extra,
});
test("every line is translated and saved page by page", async () => {
const server = fakeServer(["a", "b", "c", "d", "e"]);
const translator = fakeTranslator();
const seen = [];
const outcome = await run(translator, server, { onProgress: (p) => seen.push(p) });
assert.equal(outcome, "finished");
assert.equal(translator.created.length, 3);
assert.deepEqual(server.saved, { k0: "RU a", k1: "RU b", k2: "RU c", k3: "RU d", k4: "RU e" });
assert.deepEqual([seen.at(-1).done, seen.at(-1).failed, seen.at(-1).total], [5, 0, 5]);
});
test("a line that fails is skipped and the run still ends", async () => {
const server = fakeServer(["a", "bad", "c", "d"]);
const seen = [];
await run(fakeTranslator({ broken: ["bad"] }), server, { onProgress: (p) => seen.push(p) });
assert.deepEqual(Object.keys(server.saved), ["k0", "k2", "k3"]);
assert.deepEqual([seen.at(-1).done, seen.at(-1).failed, seen.at(-1).lastError], [3, 1, "bad"]);
});
test("empty lines are not sent to the model and do not loop forever", async () => {
const translator = fakeTranslator();
await run(translator, fakeServer(["a", " ", "", "b"]), { pageSize: 10 });
assert.deepEqual(translator.created.flat().map((i) => i.text), ["a", "b"]);
});
test("stopping cancels the job and saves nothing more", async () => {
const server = fakeServer(["a", "b", "c"]);
const translator = fakeTranslator();
const ctl = new AbortController();
const client = {
...translator,
async createJob(...args) {
const job = await translator.createJob(...args);
job.status = "running";
job.items.forEach((i) => { i.status = "pending"; i.translation = null; });
ctl.abort();
return job;
},
};
const outcome = await run(client, server, { pageSize: 3, signal: ctl.signal });
assert.equal(outcome, "stopped");
assert.deepEqual(server.saved, {});
});
test("an unreachable translator ends the run with an error", async () => {
const translator = fakeTranslator();
const dead = {
...translator,
async createJob(...args) { const j = await translator.createJob(...args); j.status = "running"; return j; },
async job() { throw new Error("offline"); },
};
await assert.rejects(run(dead, fakeServer(["a"]), { pageSize: 3 }), /offline/);
});
test("the client tells a stopped translator from one that blocks this site", async () => {
const health = { status: "ok", api_version: 2, worker_running: true, startup_errors: {} };
const ok = new TranslatorClient("http://x", async () => new Response(JSON.stringify(health)));
assert.equal((await ok.diagnose()).state, "ok");
const old = new TranslatorClient("http://x", async () => new Response(JSON.stringify({ ...health, api_version: undefined })));
assert.equal((await old.diagnose()).state, "old");
// CORS failure: the normal request throws, the opaque no-cors one succeeds
const blocked = new TranslatorClient("http://x", async (_u, init) => {
if (init?.mode === "no-cors") return new Response("");
throw new TypeError("Failed to fetch");
});
assert.equal((await blocked.diagnose()).state, "blocked");
const offline = new TranslatorClient("http://x", async () => { throw new TypeError("Failed to fetch"); });
assert.equal((await offline.diagnose()).state, "offline");
});
test("a project is updated when it exists and created when it does not", async () => {
const calls = [];
const fetcher = async (url, init) => {
calls.push(`${init.method} ${url}`);
if (init.method === "GET") return new Response(JSON.stringify([{ id: "p1", name: "Game" }]));
return new Response(JSON.stringify({ id: "new" }));
};
const client = new TranslatorClient("http://x", fetcher);
const s = { source_language: "English", target_language: "Russian", context: "", instructions: "", glossary: {}, provider: "lmstudio", model: null };
assert.equal(await client.saveProject({ ...s, name: "Game" }), "p1");
assert.equal(await client.saveProject({ ...s, name: "Other" }), "new");
assert.deepEqual(calls, ["GET http://x/projects", "PUT http://x/projects/p1", "GET http://x/projects", "POST http://x/projects"]);
});
test("addresses are completed and cleaned", () => {
assert.equal(normalizeUrl(" 127.0.0.1:8787/ "), "http://127.0.0.1:8787");
assert.equal(normalizeUrl("https://t.example//"), "https://t.example");
});