feat: language picker with flags, paging and follow mode, translation run in the floating window
Language picker with search and SVG flags, real pagination with locate, one global machine translation run shown next to uploads. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f6bd4508e
commit
7fa88e4025
82 files changed
+978
-187
No files matched your search
@@ -1,15 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import type { LangStats } from "../pages/translation-types";
|
||||
import { ErrorNote } from "../ui/Bits";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { runTranslation, type Progress, type Source } from "./run";
|
||||
import { statusText, useTranslationRun, type RunState } from "./runner";
|
||||
import type { Session } from "./session";
|
||||
import "./translator.css";
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
/** What the list below shows right now: the run walks it in the same order. */
|
||||
export interface View {
|
||||
kind: "lines" | "strings" | "glossary";
|
||||
@@ -17,23 +14,26 @@ export interface View {
|
||||
q: string;
|
||||
}
|
||||
|
||||
type State = "untranslated" | "outdated";
|
||||
|
||||
/** Machine translation of the list on screen through the translator running on this computer. */
|
||||
export function MachineTranslate({ session, versionId, lang, view, onFill, onStats, onClose }: {
|
||||
session: Session; versionId: string; lang: string; view: View;
|
||||
/** a page of translations has just been saved: show them in the rows */
|
||||
onFill: (kind: "lines" | "strings", results: { key: string; text: string }[]) => void;
|
||||
onStats: (stats: Partial<LangStats>) => void;
|
||||
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 [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [preparing, setPreparing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [summary, setSummary] = useState("");
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (link === "idle") void session.connect();
|
||||
@@ -41,84 +41,58 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
|
||||
}, []);
|
||||
|
||||
const listable = view.kind !== "glossary";
|
||||
const states = useMemo<State[]>(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]);
|
||||
const states = useMemo<RunState[]>(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]);
|
||||
|
||||
const entriesUrl = useCallback((state: State, offset: number, limit: number) =>
|
||||
const countUrl = useCallback((state: RunState) =>
|
||||
`/api/versions/${versionId}/i18n/${lang}/entries?${new URLSearchParams({
|
||||
kind: view.kind, state, module: view.module, q: view.q, offset: String(offset), limit: String(limit) })}`,
|
||||
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 (running || !listable) return;
|
||||
if (run.busy || !listable) return;
|
||||
let stale = false;
|
||||
Promise.all(states.map((s) => api.get<{ total: number }>(entriesUrl(s, 0, 1))))
|
||||
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, entriesUrl, running, listable, summary]);
|
||||
|
||||
useEffect(() => () => abort.current?.abort(), []);
|
||||
}, [states, countUrl, run.busy, listable, task?.status]);
|
||||
|
||||
const start = async () => {
|
||||
if (view.kind === "glossary") return;
|
||||
const kind = view.kind;
|
||||
setError("");
|
||||
setSummary("");
|
||||
setProgress(null);
|
||||
const ctl = new AbortController();
|
||||
abort.current = ctl;
|
||||
setRunning(true);
|
||||
const base = { total: 0, done: 0, failed: 0 };
|
||||
let last: Progress = { ...base, lastError: "", model: null };
|
||||
setPreparing(true);
|
||||
try {
|
||||
const projectId = await session.project();
|
||||
for (const state of states) {
|
||||
const outcome = await runTranslation({
|
||||
client, projectId, batchSize: prefs.batch, pageSize: PAGE, signal: ctl.signal,
|
||||
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(entriesUrl(state, skip, limit)),
|
||||
save: async (results) => {
|
||||
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
|
||||
const stats = await api.put<Partial<LangStats>>(`/api/versions/${versionId}/i18n/${lang}`,
|
||||
kind === "strings" ? { strings: changes } : { lines: changes });
|
||||
onFill(kind, results);
|
||||
onStats(stats);
|
||||
},
|
||||
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}` : ""}.`);
|
||||
const started = 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 {
|
||||
setRunning(false);
|
||||
abort.current = null;
|
||||
setPreparing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => abort.current?.abort();
|
||||
|
||||
const health = link !== "idle" && link !== "checking" && link.state === "ok" ? link.health : null;
|
||||
const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0;
|
||||
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} disabled={running} aria-label="Закрыть"><Icon name="x" size={16} /></button>
|
||||
<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>}
|
||||
@@ -139,18 +113,27 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
|
||||
)}
|
||||
{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={running} onChange={(e) => { setPref("provider", e.target.value); setPref("model", ""); }}>
|
||||
<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={running} onChange={(e) => setPref("model", e.target.value)}>
|
||||
<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>}
|
||||
@@ -161,7 +144,7 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Строк за запрос</span>
|
||||
<input type="number" min={1} max={50} value={prefs.batch} disabled={running}
|
||||
<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>
|
||||
@@ -169,16 +152,18 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
|
||||
|
||||
<label className="field">
|
||||
<span>Пожелания к переводу</span>
|
||||
<textarea rows={2} value={session.instructions} disabled={running} maxLength={5000}
|
||||
<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={prefs.about} disabled={running}
|
||||
<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={running}
|
||||
<label className="check"><input type="checkbox" checked={prefs.outdated} disabled={locked}
|
||||
onChange={(e) => setPref("outdated", e.target.checked)} /> Обновить устаревшие переводы</label>
|
||||
</div>
|
||||
|
||||
@@ -191,28 +176,25 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
|
||||
|
||||
<div className="actions">
|
||||
{running ? (
|
||||
<button className="btn" onClick={stop}><Icon name="x" size={18} />Остановить</button>
|
||||
<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}>
|
||||
<Icon name="play" size={18} />Перевести{todo ? ` · ${todo}` : ""}
|
||||
<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 && !running && <span className="muted small">Здесь всё уже переведено.</span>}
|
||||
{listable && todo === 0 && !run.busy && <span className="muted small">Здесь всё уже переведено.</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{progress && (running || summary) && (
|
||||
{mine && task && progress && (
|
||||
<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>
|
||||
<span className="muted small">{statusText(task)}{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>}
|
||||
{mine && task?.error && <ErrorNote>{task.error}</ErrorNote>}
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user