diff --git a/README.md b/README.md
index 3a9e571..e040a69 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,10 @@ Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md).
Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора —
в [docs/MODDING.md](docs/MODDING.md).
+Машинный перевод нейросетью, которая работает на компьютере переводчика (без
+участия сервера), — страница `/translator` и раздел «Машинный перевод через
+локальный переводчик» в [docs/LOCALIZATION.md](docs/LOCALIZATION.md).
+
## Установка на VM
```bash
diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md
index 835a397..686d3b5 100644
--- a/docs/LOCALIZATION.md
+++ b/docs/LOCALIZATION.md
@@ -140,8 +140,9 @@ translate russian strings:
* Публикация перевода — **слияние локали** в новую главную версию, а не
подмена главной версии форком.
* Из игр импортируются **все языки** `tl/`.
-* Машинный предперевод — **позже, как подключаемый провайдер**. Сейчас
- закладывается только состояние строки `machine`.
+* Машинный предперевод — **на стороне клиента**: переводит программа
+ [local-translator](https://gitea.hsrv.site/mrleo1nid/local-translator) на
+ компьютере переводчика, сервер библиотеки о ней не знает (см. ниже).
* Начинаем с этапа 1.
## Состояние
@@ -192,8 +193,43 @@ translate russian strings:
`…/font`, `…/export`, `…/import`, `DELETE …/{lang}`.
Пока не сделано: UI истории версии (сейчас есть только API), картинки и
-озвучка для отдельного языка, правка реплик прямо в плеере, машинный
-предперевод (заложено только состояние `machine`).
+озвучка для отдельного языка, правка реплик прямо в плеере.
+
+### Машинный перевод через локальный переводчик
+
+Страница `/translator` («Локальный переводчик» в меню профиля) объясняет, как
+поставить LM Studio и local-translator, какую модель взять по объёму
+видеопамяти, и проверяет связь прямо из браузера. В редакторе перевода кнопка
+«Машинный перевод» открывает панель: источник и модель, строк за запрос,
+пожелания к стилю (запоминаются для игры), «имена и интерфейс», «обновить
+устаревшие».
+
+Как это работает:
+
+* браузер сам ходит на `http://127.0.0.1:8787` (адрес можно сменить на странице
+ настройки); библиотека получает только обычные `PUT …/i18n/{lang}` с
+ состоянием `machine`, ни адреса, ни моделей она не видит;
+* для каждой игры и пары языков в переводчике создаётся проект
+ `PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий
+ из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и
+ обновляется перед каждым запуском;
+* строки берутся страницами по 40 с фильтром «не переведено» (и «устарело» по
+ желанию), страница уходит одним заданием, готовое сразу сохраняется. Остановка
+ отменяет задание в переводчике; повторный запуск продолжает с оставшегося.
+ Непереведённые из-за ошибки строки (например, модель повредила плейсхолдер)
+ пропускаются и видны в счётчике ошибок;
+* переводчик по умолчанию закрыт для браузера. Адрес сайта вписывается в
+ `allowed_origins` его `config.toml` (точный адрес, без `*`); для Chrome сервер
+ отвечает на заголовок Private Network Access, а сам браузер один раз спросит
+ разрешение на доступ к локальной сети. Страница настройки отличает «не запущен»
+ от «запущен, но сайт не разрешён»;
+* код: `frontend/src/translator/` (`client.ts` — API переводчика, `run.ts` —
+ цикл перевода, `MachineTranslate.tsx` — панель, `TranslatorSetup.tsx` —
+ страница настройки); проверки в `frontend/tests/translator-run.test.mjs`.
+
+Ограничения: перевод идёт, пока открыта страница редактора; контекст
+(кто говорит, соседние реплики) модели пока не передаётся, только текст строки;
+Safari может запрещать https-странице обращаться к локальному http.
Уже сконвертированные версии остаются без ID и без переводов, их нужно
переимпортировать. Пока в библиотеке только тестовые данные, отдельную
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index bffb714..6f73c67 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
Аккаунт и пароль
Сохранения
+ {can("translations.edit") && (
+ Локальный переводчик
+ )}
{(can("users.manage") || can("roles.manage")) &&
Администрирование
}
{can("users.manage") && (
Пользователи
@@ -115,6 +119,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/pages/TranslationEditor.tsx b/frontend/src/pages/TranslationEditor.tsx
index aac802d..6448ae9 100644
--- a/frontend/src/pages/TranslationEditor.tsx
+++ b/frontend/src/pages/TranslationEditor.tsx
@@ -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(null);
const [ov, setOv] = useState(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); }} />
)}
+ {ov.editable && (
+ setMachine((m) => !m)}
+ title="Перевод нейросетью, которая работает на вашем компьютере">
+ Машинный перевод
+
+ )}
Играть на этом языке
>
)}
+ {machine && ov.editable && stats && (
+ setMachine(false)}
+ onChanged={() => void loadOverview().then(() => setParam("r", String(Date.now())))} />
+ )}
+
{!ov.languages.length ? (
void; onChanged: () => void;
+}) {
+ const client = useMemo(() => new TranslatorClient(normalizeUrl(savedUrl())), []);
+ const [link, setLink] = useState("checking");
+ const [providers, setProviders] = useState([]);
+ const [models, setModels] = useState(null);
+ const [prefs, setPrefs] = useState(loadPrefs);
+ const [instructions, setInstructions] = useState(() => readText(instructionsKey(version.game_id)));
+ const [glossary, setGlossary] = useState([]);
+ const [game, setGame] = useState(null);
+ const [todo, setTodo] = useState(null);
+ const [running, setRunning] = useState(false);
+ const [progress, setProgress] = useState(null);
+ const [error, setError] = useState("");
+ const [summary, setSummary] = useState("");
+ const abort = useRef(null);
+
+ const setPref = (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(`/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(() => {
+ 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 (
+
+
+
Машинный перевод
+
+
+
+ Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой
+ «Машинный». Как установить и настроить
+
+
+ {link === "checking" && Ищу переводчик на {client.base}…
}
+ {link !== "checking" && link.state === "offline" && (
+ Переводчик не отвечает на {client.base}. Запустите его ( инструкция)
+ и нажмите «Проверить снова».
+ )}
+ {link !== "checking" && link.state === "blocked" && (
+ Переводчик запущен, но браузер не пускает этот сайт к нему. Добавьте {location.origin} в
+ allowed_origins в config.toml и перезапустите переводчик ( подробнее).
+ )}
+ {link !== "checking" && link.state === "old" && (
+ Версия переводчика слишком старая: обновите репозиторий local-translator и перезапустите его.
+ )}
+ {link !== "checking" && link.state !== "ok" && (
+ void connect()}> Проверить снова
+ )}
+ {health?.startup_errors[prefs.provider] && {health.startup_errors[prefs.provider]} }
+
+ {ready && (
+ <>
+
+
+ Источник
+ { setPref("provider", e.target.value); setPref("model", ""); }}>
+ {providers.map((p) => {p} )}
+
+
+
+ Модель
+ setPref("model", e.target.value)}>
+ Как в настройках переводчика
+ {(models ?? []).map((m) => {m} )}
+ {prefs.model && !(models ?? []).includes(prefs.model) && {prefs.model} }
+
+ {models !== null && !models.length && (
+ Список моделей пуст: запустите сервер в LM Studio и загрузите модель.
+ )}
+
+
+ Строк за запрос
+ setPref("batch", Math.max(1, Math.min(50, Number(e.target.value) || 10)))} />
+ Меньше — надёжнее для слабых моделей.
+
+
+
+
+ Пожелания к переводу
+
+
+
+ setPref("strings", e.target.checked)} /> Имена, меню и интерфейс
+ setPref("outdated", e.target.checked)} /> Обновить устаревшие переводы
+
+
+
+ {running ? (
+ Остановить
+ ) : (
+ void start()} disabled={!todo || !prefs.provider}>
+ Перевести{todo ? ` · ${todo}` : ""}
+
+ )}
+ {todo === 0 && !running && Всё уже переведено. }
+
+ >
+ )}
+
+ {progress && (running || summary) && (
+
+
+
+ {progress.done} из {progress.total}{progress.failed ? ` · ошибок ${progress.failed}` : ""}
+ {progress.model ? ` · ${progress.model}` : ""}
+
+
+ )}
+ {progress?.lastError && progress.failed > 0 && Последняя ошибка: {progress.lastError}
}
+ {summary && {summary}
}
+ {error && {error} }
+
+ );
+}
diff --git a/frontend/src/translator/TranslatorSetup.tsx b/frontend/src/translator/TranslatorSetup.tsx
new file mode 100644
index 0000000..7b163a0
--- /dev/null
+++ b/frontend/src/translator/TranslatorSetup.tsx
@@ -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 (
+
+
{children}
+ {copy && (
+
{
+ navigator.clipboard.writeText(children).then(() => ui.toast("Скопировано", "ok"), () => ui.toast("Не удалось скопировать", "error"));
+ }}> Копировать
+ )}
+
+ );
+}
+
+function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
+ return (
+
+ {n} {title}
+ {children}
+
+ );
+}
+
+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: <>Добавьте адрес сайта в allowed_origins (шаг 3) и перезапустите переводчик. Если уже добавили — проверьте, что адрес совпадает до символа, включая https и порт.> },
+ ] };
+ }
+ checks.push({ ok: true, text: "Переводчик запущен и отвечает этому сайту" });
+ if (link.state === "old") {
+ checks.push({ ok: false, text: "Версия переводчика устарела", hint: <>Выполните git pull в папке переводчика и перезапустите его.> });
+ 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(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 (
+
+
+
+
+
Как это устроено
+
+ Переводит программа local-translator , которая запускается у вас на компьютере и использует локальную
+ модель через LM Studio. Страница редактора перевода в браузере обращается к ней напрямую, а библиотека
+ получает только готовые строки. Сервер библиотеки не знает ни о переводчике, ни о модели.
+
+
+ Браузер с этим сайтом → local-translator127.0.0.1:8787
+ → LM Studio и модель127.0.0.1:1234
+
+
+ Перевод идёт построчно и сохраняется после каждой пачки с пометкой «Машинный»: его можно остановить в любой момент,
+ а потом проверить и поправить вручную. Работает только при открытой странице редактора.
+
+
+
+
+
+ Скачайте LM Studio , найдите модель
+ в поиске и загрузите её. Затем откройте вкладку Developer и нажмите Start Server (порт 1234).
+
+
+ Видеопамять Что взять Заметки
+
+ 6–8 ГБ HY-MT2-7B, Q4_K_M (4,6 ГБ)Минимум для комфортной работы; лучше пачки по 5 строк.
+ 12 ГБ HY-MT2-7B, Q6_K (6,2 ГБ)Разумный выбор по умолчанию для RTX 3060.
+ 16 ГБ и больше HY-MT2-7B, Q8_0 (8 ГБ)Почти без потерь качества, остаётся запас на контекст.
+ Без видеокарты та же модель, Q4_K_M Работает на процессоре, но в разы медленнее.
+
+
+
+ HY-MT2 — специализированная модель перевода от Tencent, переводчик настроен под неё. Размеры — это только
+ веса, контексту нужна дополнительная память. Это отправная точка, а не измерение на вашем железе:
+ переведите 20–30 строк игры и сравните два варианта. Если игра откровенная, проверьте, что модель не
+ смягчает и не отказывается переводить; тогда попробуйте другую (например, Qwen3-8B abliterated).
+ Нужен перевод из облака — подойдёт OpenRouter, но тогда текст уходит на сторону.
+
+
+
+
+ Нужен Python 3.11 или новее.
+ Windows:
+ {`git clone ${REPO}\ncd local-translator\nscripts\\windows\\start.bat`}
+ Linux и macOS:
+ {`git clone ${REPO}\ncd local-translator\nbash start.sh`}
+
+ При первом запуске создаётся окружение и копируется config.toml. Окно должно оставаться открытым,
+ пока вы переводите. Если у вас несколько моделей, впишите точное имя в providers.lmstudio.model
+ или выбирайте модель прямо в панели перевода.
+
+
+
+
+
+ По умолчанию переводчик закрыт для браузера, иначе любой сайт мог бы пользоваться вашей моделью. Откройте
+ config.toml, найдите строку allowed_origins и впишите адрес этой библиотеки:
+
+ {config}
+
+ Адрес берётся из вашей адресной строки, без пути в конце. После правки перезапустите переводчик.
+ Для разработки можно добавить несколько адресов через запятую.
+
+
+
+
+
+ {checks && (
+
+ {checks.map((c, i) => (
+
+
+ {c.text} {c.hint &&
{c.hint}
}
+
+ ))}
+
+ )}
+ {checks && checks.every((c) => c.ok) && (
+ Всё готово. Откройте редактор перевода игры и нажмите «Машинный перевод».
+ )}
+
+
+
+
Браузеры и неполадки
+
+ Chrome и Edge при первом обращении спросят разрешение на доступ к устройствам в локальной сети.
+ Нажмите «Разрешить», иначе связи не будет.
+ Firefox обычно пропускает обращения к 127.0.0.1 без вопросов.
+ Safari может запрещать страницам по https обращаться к локальному http-адресу. Используйте другой браузер.
+ Блокировщики рекламы и расширения безопасности иногда мешают; попробуйте отключить их для этого сайта.
+ Перевод остановился или медленный? Уменьшите «строк за запрос» в панели или возьмите квантовку поменьше.
+ Подробности пишутся в окно переводчика.
+ Тексты со вставками вида {"{name}"}, [var] и тегами переводчик защищает сам и не принимает
+ ответ, где они повреждены: такая строка остаётся непереведённой, и её видно по счётчику ошибок.
+
+
+
+ );
+}
diff --git a/frontend/src/translator/client.ts b/frontend/src/translator/client.ts
new file mode 100644
index 0000000..58a068c
--- /dev/null
+++ b/frontend/src/translator/client.ts
@@ -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;
+}
+
+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;
+ 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(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise {
+ 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 {
+ try {
+ const health = await this.call("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> {
+ return this.call("GET", "/providers");
+ }
+
+ async models(provider: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ return this.call("GET", `/jobs/${id}`, undefined, signal);
+ }
+
+ cancel(id: string): Promise {
+ 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 {
+ try {
+ const name = new Intl.DisplayNames(["en"], { type: "language" }).of(code);
+ if (name && name !== code) return name;
+ } catch {
+ /* invalid code */
+ }
+ return known?.[code] ?? code;
+}
diff --git a/frontend/src/translator/run.ts b/frontend/src/translator/run.ts
new file mode 100644
index 0000000..2434308
--- /dev/null
+++ b/frontend/src/translator/run.ts
@@ -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;
+ 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;
+ 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((resolve) => {
+ const t = setTimeout(resolve, ms);
+ signal.addEventListener("abort", () => {
+ clearTimeout(t);
+ resolve();
+ }, { once: true });
+ });
+
+async function waitForJob(opts: RunOptions, first: Job): Promise {
+ 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 {
+ 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";
+}
diff --git a/frontend/src/translator/translator.css b/frontend/src/translator/translator.css
new file mode 100644
index 0000000..5241db4
--- /dev/null
+++ b/frontend/src/translator/translator.css
@@ -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; }
diff --git a/frontend/tests/translator-run.test.mjs b/frontend/tests/translator-run.test.mjs
new file mode 100644
index 0000000..43e130d
--- /dev/null
+++ b/frontend/tests/translator-run.test.mjs
@@ -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");
+});