feat: machine translation through a local translator running in the browser's machine
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:
1 parent
a1f5ef8e2d
commit
e8d6bf4646
10 files changed
+1039
-4
No files matched your search
@@ -30,6 +30,10 @@ Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md).
|
|||||||
Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора —
|
Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора —
|
||||||
в [docs/MODDING.md](docs/MODDING.md).
|
в [docs/MODDING.md](docs/MODDING.md).
|
||||||
|
|
||||||
|
Машинный перевод нейросетью, которая работает на компьютере переводчика (без
|
||||||
|
участия сервера), — страница `/translator` и раздел «Машинный перевод через
|
||||||
|
локальный переводчик» в [docs/LOCALIZATION.md](docs/LOCALIZATION.md).
|
||||||
|
|
||||||
## Установка на VM
|
## Установка на VM
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+40
-4
@@ -140,8 +140,9 @@ translate russian strings:
|
|||||||
* Публикация перевода — **слияние локали** в новую главную версию, а не
|
* Публикация перевода — **слияние локали** в новую главную версию, а не
|
||||||
подмена главной версии форком.
|
подмена главной версии форком.
|
||||||
* Из игр импортируются **все языки** `tl/`.
|
* Из игр импортируются **все языки** `tl/`.
|
||||||
* Машинный предперевод — **позже, как подключаемый провайдер**. Сейчас
|
* Машинный предперевод — **на стороне клиента**: переводит программа
|
||||||
закладывается только состояние строки `machine`.
|
[local-translator](https://gitea.hsrv.site/mrleo1nid/local-translator) на
|
||||||
|
компьютере переводчика, сервер библиотеки о ней не знает (см. ниже).
|
||||||
* Начинаем с этапа 1.
|
* Начинаем с этапа 1.
|
||||||
|
|
||||||
## Состояние
|
## Состояние
|
||||||
@@ -192,8 +193,43 @@ translate russian strings:
|
|||||||
`…/font`, `…/export`, `…/import`, `DELETE …/{lang}`.
|
`…/font`, `…/export`, `…/import`, `DELETE …/{lang}`.
|
||||||
|
|
||||||
Пока не сделано: UI истории версии (сейчас есть только API), картинки и
|
Пока не сделано: 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 и без переводов, их нужно
|
Уже сконвертированные версии остаются без ID и без переводов, их нужно
|
||||||
переимпортировать. Пока в библиотеке только тестовые данные, отдельную
|
переимпортировать. Пока в библиотеке только тестовые данные, отдельную
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Play } from "./pages/Play";
|
|||||||
import { Roles } from "./pages/Roles";
|
import { Roles } from "./pages/Roles";
|
||||||
import { Saves } from "./pages/Saves";
|
import { Saves } from "./pages/Saves";
|
||||||
import { TranslationEditor } from "./pages/TranslationEditor";
|
import { TranslationEditor } from "./pages/TranslationEditor";
|
||||||
|
import { TranslatorSetup } from "./translator/TranslatorSetup";
|
||||||
import { Users } from "./pages/Users";
|
import { Users } from "./pages/Users";
|
||||||
import { useTheme } from "./theme";
|
import { useTheme } from "./theme";
|
||||||
import { Icon } from "./ui/Icon";
|
import { Icon } from "./ui/Icon";
|
||||||
@@ -62,6 +63,9 @@ function UserMenu() {
|
|||||||
</div>
|
</div>
|
||||||
<NavLink to="/account" className="menu-item" role="menuitem"><Icon name="key" size={18} />Аккаунт и пароль</NavLink>
|
<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>
|
<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") || can("roles.manage")) && <div className="menu-section">Администрирование</div>}
|
||||||
{can("users.manage") && (
|
{can("users.manage") && (
|
||||||
<NavLink to="/admin/users" className="menu-item" role="menuitem"><Icon name="users" size={18} />Пользователи</NavLink>
|
<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/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
|
||||||
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></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="/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="/versions/:versionId/edit" element={<RequirePerm perm="games.play"><ModEditor /></RequirePerm>} />
|
||||||
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
|
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
|
||||||
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
|
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api, uploadRaw, type Version } from "../api";
|
|||||||
import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits";
|
import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { useUi } from "../ui/Ui";
|
import { useUi } from "../ui/Ui";
|
||||||
|
import { MachineTranslate } from "../translator/MachineTranslate";
|
||||||
import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts";
|
import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts";
|
||||||
import {
|
import {
|
||||||
FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE,
|
FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE,
|
||||||
@@ -19,6 +20,7 @@ export function TranslationEditor() {
|
|||||||
const [version, setVersion] = useState<Version | null>(null);
|
const [version, setVersion] = useState<Version | null>(null);
|
||||||
const [ov, setOv] = useState<Overview | null>(null);
|
const [ov, setOv] = useState<Overview | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [machine, setMachine] = useState(false);
|
||||||
const lang = params.get("lang") ?? "";
|
const lang = params.get("lang") ?? "";
|
||||||
const tab = (params.get("tab") as Tab) ?? "lines";
|
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); }} />
|
onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void uploadFont(f); }} />
|
||||||
</label>
|
</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>
|
<Link className="btn btn-sm btn-ghost" to={`/play/${versionId}?lang=${lang}`}><Icon name="play" size={16} />Играть на этом языке</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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 ? (
|
{!ov.languages.length ? (
|
||||||
<EmptyState icon="globe" title="Переводов пока нет"
|
<EmptyState icon="globe" title="Переводов пока нет"
|
||||||
text={ov.editable ? "Добавьте язык, чтобы начать перевод." : "У этой версии нет переводов."}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user