Files
pgames-manager/frontend/src/translator/MachineTranslate.tsx
T
mrleo1nidandClaude Sonnet 5.5 68b66a1d94
CI / frontend (push) Failing after 14s
CI / backend (push) Failing after 49s
feat: machine translation resumes after a page reload
The run is recorded at every page and picked up from the translator's finished job; one run across tabs via Web Locks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 20:39:37 +03:00

202 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { ErrorNote } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { statusText, useTranslationRun, type RunState } from "./runner";
import type { Session } from "./session";
import "./translator.css";
/** What the list below shows right now: the run walks it in the same order. */
export interface View {
kind: "lines" | "strings" | "glossary";
module: string;
q: string;
}
/** Machine translation of the list on screen through the translator running on this computer. */
export function MachineTranslate({ session, versionId, lang, title, view, follow, onFollow, onClose }: {
session: Session; versionId: string; lang: string;
/** the game and version, for the card in the floating window */
title: string;
view: View;
/** the list jumps to the page being translated */
follow: boolean;
onFollow: (on: boolean) => void;
onClose: () => void;
}) {
const { client, link, ready, prefs, setPref } = session;
const run = useTranslationRun();
const task = run.task;
const mine = !!task && task.versionId === versionId && task.lang === lang;
const running = run.busy && mine;
const elsewhere = run.busy && !mine;
const [todo, setTodo] = useState<number | null>(null);
const [preparing, setPreparing] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (link === "idle") void session.connect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const listable = view.kind !== "glossary";
const states = useMemo<RunState[]>(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]);
const countUrl = useCallback((state: RunState) =>
`/api/versions/${versionId}/i18n/${lang}/entries?${new URLSearchParams({
kind: view.kind, state, module: view.module, q: view.q, offset: "0", limit: "1" })}`,
[versionId, lang, view.kind, view.module, view.q]);
// how much there is to translate with the current list and options
useEffect(() => {
if (run.busy || !listable) return;
let stale = false;
Promise.all(states.map((s) => api.get<{ total: number }>(countUrl(s))))
.then((r) => !stale && setTodo(r.reduce((n, x) => n + x.total, 0))).catch(() => !stale && setTodo(null));
return () => {
stale = true;
};
}, [states, countUrl, run.busy, listable, task?.status]);
const start = async () => {
if (view.kind === "glossary") return;
setError("");
setPreparing(true);
try {
const projectId = await session.project();
const started = await run.start({
client, projectId, versionId, lang, title, kind: view.kind, module: view.module, q: view.q,
states, batch: prefs.batch,
});
if (!started) setError("Другой перевод уже идёт (возможно, в другой вкладке): дождитесь его или остановите.");
} catch (e) {
setError((e as Error).message);
} finally {
setPreparing(false);
}
};
const health = link !== "idle" && link !== "checking" && link.state === "ok" ? link.health : null;
const scope = view.kind === "strings" ? "имена, меню и интерфейс" : "реплики";
const progress = mine && task ? task.progress : null;
const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0;
const locked = running || elsewhere;
return (
<section className="card mt-panel" aria-label="Машинный перевод">
<div className="mt-head">
<h2>Машинный перевод</h2>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label="Закрыть"><Icon name="x" size={16} /></button>
</div>
<p className="muted small">
Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой
«Машинный». <Link to="/translator">Как установить и настроить</Link>. Отдельную строку можно перевести
кнопкой «Перевести» рядом с ней. Перевод идёт в фоне: ход виден в окне справа внизу, можно переходить по сайту.
</p>
{(link === "idle" || link === "checking") && <p className="muted small">Ищу переводчик на {client.base}…</p>}
{typeof link === "object" && link.state === "offline" && (
<ErrorNote>Переводчик не отвечает на {client.base}. Запустите его (<Link to="/translator">инструкция</Link>)
и нажмите «Проверить снова».</ErrorNote>
)}
{typeof link === "object" && link.state === "blocked" && (
<ErrorNote>Переводчик запущен, но браузер не пускает этот сайт к нему. Добавьте <b>{location.origin}</b> в
allowed_origins в config.toml (в начале файла, выше первой секции) и перезапустите переводчик
(<Link to="/translator">подробнее</Link>).</ErrorNote>
)}
{typeof link === "object" && link.state === "old" && (
<ErrorNote>Версия переводчика слишком старая: обновите репозиторий local-translator и перезапустите его.</ErrorNote>
)}
{typeof link === "object" && link.state !== "ok" && (
<div className="actions"><button className="btn btn-sm" onClick={() => void session.connect()}><Icon name="retry" size={16} />Проверить снова</button></div>
)}
{health?.startup_errors[prefs.provider] && <ErrorNote>{health.startup_errors[prefs.provider]}</ErrorNote>}
{elsewhere && task && (
<div className="note note-info">
<Icon name="info" size={18} />
<span>Сейчас идёт другой перевод: «{task.title}» ({task.lang}). Одновременно работает только один.
Дождитесь его окончания или остановите.{" "}
<button className="btn btn-sm" onClick={run.stop} disabled={task.status === "stopping"}>Остановить</button></span>
</div>
)}
{ready && (
<>
<div className="form-row">
<label className="field">
<span>Источник</span>
<select value={prefs.provider} disabled={locked} onChange={(e) => { setPref("provider", e.target.value); setPref("model", ""); }}>
{session.providers.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
<label className="field">
<span>Модель</span>
<select value={prefs.model} disabled={locked} onChange={(e) => setPref("model", e.target.value)}>
<option value="">Как в настройках переводчика</option>
{(session.models ?? []).map((m) => <option key={m} value={m}>{m}</option>)}
{prefs.model && !(session.models ?? []).includes(prefs.model) && <option value={prefs.model}>{prefs.model}</option>}
</select>
{session.models !== null && !session.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={locked}
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={session.instructions} disabled={locked} maxLength={5000}
placeholder="Например: обращение на «ты», сохраняй грубость оригинала. Запоминается для этой игры."
onChange={(e) => session.setInstructions(e.target.value)} />
<small className="hint">Имена и термины берутся из вкладки «Глоссарий».</small>
</label>
<div className="mt-options">
<label className="check" title="Список сам открывает страницу, которую сейчас переводит модель">
<input type="checkbox" checked={follow} onChange={(e) => onFollow(e.target.checked)} /> Следить за переводом в списке</label>
<label className="check" title="Описание и жанры помогают выбрать тон и термины"><input type="checkbox" checked={prefs.about} disabled={locked}
onChange={(e) => setPref("about", e.target.checked)} /> Передавать описание и жанры игры как контекст</label>
<label className="check"><input type="checkbox" checked={prefs.outdated} disabled={locked}
onChange={(e) => setPref("outdated", e.target.checked)} /> Обновить устаревшие переводы</label>
</div>
<p className="muted small">
{listable
? <>Переводится то, что открыто ниже: {scope}{view.module ? `, файл ${view.module.replace(/^script\//, "").replace(/\.json$/, "")}` : ""}
{view.q ? `, по поиску «${view.q}»` : ""}. Идёт сверху вниз, как в списке, переведённое появляется в строках сразу.</>
: "Откройте вкладку «Реплики» или «Имена и меню», чтобы переводить."}
</p>
<div className="actions">
{running ? (
<button className="btn" onClick={run.stop} disabled={task?.status === "stopping"}><Icon name="x" size={18} />Остановить</button>
) : (
<button className="btn btn-primary" onClick={() => void start()}
disabled={!listable || !todo || !prefs.provider || elsewhere || preparing}>
<Icon name="play" size={18} />{preparing ? "Готовлю…" : `Перевести${todo ? ` · ${todo}` : ""}`}
</button>
)}
{listable && todo === 0 && !run.busy && <span className="muted small">Здесь всё уже переведено.</span>}
</div>
</>
)}
{mine && task && progress && (
<div className="mt-progress" aria-live="polite">
<div className="progress"><div style={{ width: `${percent}%` }} /></div>
<span className="muted small">{statusText(task)}{progress.model ? ` · модель: ${progress.model}` : ""}</span>
</div>
)}
{mine && task?.error && <ErrorNote>{task.error}</ErrorNote>}
{error && <ErrorNote>{error}</ErrorNote>}
</section>
);
}