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