diff --git a/README.md b/README.md index 3a9e571..e040a69 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md). Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора — в [docs/MODDING.md](docs/MODDING.md). +Машинный перевод нейросетью, которая работает на компьютере переводчика (без +участия сервера), — страница `/translator` и раздел «Машинный перевод через +локальный переводчик» в [docs/LOCALIZATION.md](docs/LOCALIZATION.md). + ## Установка на VM ```bash diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index 835a397..686d3b5 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -140,8 +140,9 @@ translate russian strings: * Публикация перевода — **слияние локали** в новую главную версию, а не подмена главной версии форком. * Из игр импортируются **все языки** `tl/`. -* Машинный предперевод — **позже, как подключаемый провайдер**. Сейчас - закладывается только состояние строки `machine`. +* Машинный предперевод — **на стороне клиента**: переводит программа + [local-translator](https://gitea.hsrv.site/mrleo1nid/local-translator) на + компьютере переводчика, сервер библиотеки о ней не знает (см. ниже). * Начинаем с этапа 1. ## Состояние @@ -192,8 +193,43 @@ translate russian strings: `…/font`, `…/export`, `…/import`, `DELETE …/{lang}`. Пока не сделано: UI истории версии (сейчас есть только API), картинки и -озвучка для отдельного языка, правка реплик прямо в плеере, машинный -предперевод (заложено только состояние `machine`). +озвучка для отдельного языка, правка реплик прямо в плеере. + +### Машинный перевод через локальный переводчик + +Страница `/translator` («Локальный переводчик» в меню профиля) объясняет, как +поставить LM Studio и local-translator, какую модель взять по объёму +видеопамяти, и проверяет связь прямо из браузера. В редакторе перевода кнопка +«Машинный перевод» открывает панель: источник и модель, строк за запрос, +пожелания к стилю (запоминаются для игры), «имена и интерфейс», «обновить +устаревшие». + +Как это работает: + +* браузер сам ходит на `http://127.0.0.1:8787` (адрес можно сменить на странице + настройки); библиотека получает только обычные `PUT …/i18n/{lang}` с + состоянием `machine`, ни адреса, ни моделей она не видит; +* для каждой игры и пары языков в переводчике создаётся проект + `PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий + из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и + обновляется перед каждым запуском; +* строки берутся страницами по 40 с фильтром «не переведено» (и «устарело» по + желанию), страница уходит одним заданием, готовое сразу сохраняется. Остановка + отменяет задание в переводчике; повторный запуск продолжает с оставшегося. + Непереведённые из-за ошибки строки (например, модель повредила плейсхолдер) + пропускаются и видны в счётчике ошибок; +* переводчик по умолчанию закрыт для браузера. Адрес сайта вписывается в + `allowed_origins` его `config.toml` (точный адрес, без `*`); для Chrome сервер + отвечает на заголовок Private Network Access, а сам браузер один раз спросит + разрешение на доступ к локальной сети. Страница настройки отличает «не запущен» + от «запущен, но сайт не разрешён»; +* код: `frontend/src/translator/` (`client.ts` — API переводчика, `run.ts` — + цикл перевода, `MachineTranslate.tsx` — панель, `TranslatorSetup.tsx` — + страница настройки); проверки в `frontend/tests/translator-run.test.mjs`. + +Ограничения: перевод идёт, пока открыта страница редактора; контекст +(кто говорит, соседние реплики) модели пока не передаётся, только текст строки; +Safari может запрещать https-странице обращаться к локальному http. Уже сконвертированные версии остаются без ID и без переводов, их нужно переимпортировать. Пока в библиотеке только тестовые данные, отдельную diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bffb714..6f73c67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { Play } from "./pages/Play"; import { Roles } from "./pages/Roles"; import { Saves } from "./pages/Saves"; import { TranslationEditor } from "./pages/TranslationEditor"; +import { TranslatorSetup } from "./translator/TranslatorSetup"; import { Users } from "./pages/Users"; import { useTheme } from "./theme"; import { Icon } from "./ui/Icon"; @@ -62,6 +63,9 @@ function UserMenu() { Аккаунт и пароль Сохранения + {can("translations.edit") && ( + Локальный переводчик + )} {(can("users.manage") || can("roles.manage")) &&
Администрирование
} {can("users.manage") && ( Пользователи @@ -115,6 +119,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/TranslationEditor.tsx b/frontend/src/pages/TranslationEditor.tsx index aac802d..6448ae9 100644 --- a/frontend/src/pages/TranslationEditor.tsx +++ b/frontend/src/pages/TranslationEditor.tsx @@ -4,6 +4,7 @@ import { api, uploadRaw, type Version } from "../api"; import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits"; import { Icon } from "../ui/Icon"; import { useUi } from "../ui/Ui"; +import { MachineTranslate } from "../translator/MachineTranslate"; import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts"; import { FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE, @@ -19,6 +20,7 @@ export function TranslationEditor() { const [version, setVersion] = useState(null); const [ov, setOv] = useState(null); const [error, setError] = useState(""); + const [machine, setMachine] = useState(false); const lang = params.get("lang") ?? ""; const tab = (params.get("tab") as Tab) ?? "lines"; @@ -181,12 +183,24 @@ export function TranslationEditor() { onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void uploadFont(f); }} /> )} + {ov.editable && ( + + )} Играть на этом языке )} + {machine && ov.editable && stats && ( + setMachine(false)} + onChanged={() => void loadOverview().then(() => setParam("r", String(Date.now())))} /> + )} + {!ov.languages.length ? ( void; onChanged: () => void; +}) { + const client = useMemo(() => new TranslatorClient(normalizeUrl(savedUrl())), []); + const [link, setLink] = useState("checking"); + const [providers, setProviders] = useState([]); + const [models, setModels] = useState(null); + const [prefs, setPrefs] = useState(loadPrefs); + const [instructions, setInstructions] = useState(() => readText(instructionsKey(version.game_id))); + const [glossary, setGlossary] = useState([]); + const [game, setGame] = useState(null); + const [todo, setTodo] = useState(null); + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(""); + const [summary, setSummary] = useState(""); + const abort = useRef(null); + + const setPref = (key: K, value: Prefs[K]) => { + setPrefs((p) => { + const next = { ...p, [key]: value }; + try { + localStorage.setItem(PREFS_KEY, JSON.stringify(next)); + } catch { + /* the choice just is not remembered */ + } + return next; + }); + }; + + const connect = useCallback(async () => { + setLink("checking"); + const result = await client.diagnose(); + setLink(result); + if (result.state !== "ok") return; + try { + const list = Object.keys(await client.providers()); + setProviders(list); + setPrefs((p) => (list.includes(p.provider) ? p : { ...p, provider: list[0] ?? "" })); + } catch { + setProviders([]); + } + }, [client]); + + useEffect(() => void connect(), [connect]); + + useEffect(() => { + api.get(`/api/games/${version.game_id}`).then(setGame).catch(() => {}); + api.get<{ glossary: GlossaryTerm[] }>(`/api/versions/${versionId}/i18n/${lang}/meta`) + .then((m) => setGlossary(m.glossary ?? [])).catch(() => {}); + }, [version.game_id, versionId, lang]); + + useEffect(() => { + if (!prefs.provider || link === "checking" || link.state !== "ok") return; + let stale = false; + setModels(null); + client.models(prefs.provider).then((m) => !stale && setModels(m)).catch(() => !stale && setModels([])); + return () => { + stale = true; + }; + }, [client, link, prefs.provider]); + + const phases = useMemo(() => { + const states: Phase["state"][] = prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]; + const kinds: Phase["kind"][] = prefs.strings ? ["lines", "strings"] : ["lines"]; + return kinds.flatMap((kind) => states.map((state) => ({ kind, state }))); + }, [prefs.outdated, prefs.strings]); + + const entriesUrl = useCallback((p: Phase, offset: number, limit: number) => + `/api/versions/${versionId}/i18n/${lang}/entries?${new URLSearchParams({ + kind: p.kind, state: p.state, offset: String(offset), limit: String(limit) })}`, [versionId, lang]); + + // how much there is to translate with the current choices + useEffect(() => { + if (running) return; + let stale = false; + Promise.all(phases.map((p) => api.get<{ total: number }>(entriesUrl(p, 0, 1)))) + .then((r) => !stale && setTodo(r.reduce((n, x) => n + x.total, 0))).catch(() => !stale && setTodo(null)); + return () => { + stale = true; + }; + }, [phases, entriesUrl, running, summary]); + + useEffect(() => () => abort.current?.abort(), []); + + const start = async () => { + setError(""); + setSummary(""); + const ctl = new AbortController(); + abort.current = ctl; + setRunning(true); + const base = { total: 0, done: 0, failed: 0 }; + let last: Progress = { ...base, lastError: "", model: null }; + try { + try { + localStorage.setItem(instructionsKey(version.game_id), instructions); + } catch { + /* not remembered */ + } + const title = game?.title ?? version.name; + const projectId = await client.saveProject({ + name: `PGames: ${title} (${ov.sourceLang ?? "?"} → ${lang})`, + source_language: languageName(ov.sourceLang ?? "en", ov.knownLanguages), + target_language: languageName(lang, ov.knownLanguages), + context: [`Game: ${title}.`, game?.description, game?.tags?.length ? `Tags: ${game.tags.join(", ")}.` : ""] + .filter(Boolean).join(" ").slice(0, 4000), + instructions: instructions.trim(), + glossary: Object.fromEntries(glossary.filter((g) => g.src.trim() && g.t.trim()).map((g) => [g.src.trim(), g.t.trim()])), + provider: prefs.provider || "lmstudio", + model: prefs.model || null, + }); + for (const phase of phases) { + const outcome = await runTranslation({ + client, projectId, batchSize: prefs.batch, pageSize: PAGE, signal: ctl.signal, + fetchPage: (skip, limit) => api.get<{ total: number; items: (Source & { translation?: string })[] }>( + entriesUrl(phase, skip, limit)), + save: async (results) => { + const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }])); + await api.put(`/api/versions/${versionId}/i18n/${lang}`, + phase.kind === "strings" ? { strings: changes } : { lines: changes }); + }, + onProgress: (p) => { + last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed }; + setProgress(last); + }, + }); + Object.assign(base, { total: last.total, done: last.done, failed: last.failed }); + if (outcome === "stopped") break; + } + setSummary(ctl.signal.aborted ? `Остановлено. Переведено строк: ${last.done}.` + : `Готово. Переведено строк: ${last.done}${last.failed ? `, не удалось: ${last.failed}` : ""}.`); + } catch (e) { + setError((e as Error).message); + } finally { + setRunning(false); + abort.current = null; + onChanged(); + } + }; + + const stop = () => abort.current?.abort(); + + const health = link !== "checking" && link.state === "ok" ? link.health : null; + const ready = health !== null; + const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0; + + return ( +
+
+

Машинный перевод

+ +
+

+ Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой + «Машинный». Как установить и настроить +

+ + {link === "checking" &&

Ищу переводчик на {client.base}…

} + {link !== "checking" && link.state === "offline" && ( + Переводчик не отвечает на {client.base}. Запустите его (инструкция) + и нажмите «Проверить снова». + )} + {link !== "checking" && link.state === "blocked" && ( + Переводчик запущен, но браузер не пускает этот сайт к нему. Добавьте {location.origin} в + allowed_origins в config.toml и перезапустите переводчик (подробнее). + )} + {link !== "checking" && link.state === "old" && ( + Версия переводчика слишком старая: обновите репозиторий local-translator и перезапустите его. + )} + {link !== "checking" && link.state !== "ok" && ( +
+ )} + {health?.startup_errors[prefs.provider] && {health.startup_errors[prefs.provider]}} + + {ready && ( + <> +
+ + + +
+ +