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>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface RunOptions {
|
||||
/** 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>;
|
||||
/** a page is about to be sent to the model: these are the lines being translated now */
|
||||
onPage?: (items: Source[]) => void;
|
||||
onProgress: (p: Progress) => void;
|
||||
pollMs?: number;
|
||||
}
|
||||
@@ -82,6 +84,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
||||
skip += page.items.length - items.length;
|
||||
if (!items.length) continue;
|
||||
|
||||
opts.onPage?.(items);
|
||||
let job = await opts.client.createJob(
|
||||
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
|
||||
job = await waitForJob(opts.client, job, opts.signal, opts.pollMs);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// The one machine translation run of this browser. It lives above the pages, so it goes on while
|
||||
// the user moves around the site, and its card sits in the same floating window as the uploads.
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } 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 type { TranslatorClient } from "./client";
|
||||
import { runTranslation, type Progress, type Source } from "./run";
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
export type RunState = "untranslated" | "outdated";
|
||||
|
||||
export interface RunSpec {
|
||||
client: TranslatorClient;
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
lang: string;
|
||||
/** shown in the floating card: the game and version being translated */
|
||||
title: string;
|
||||
kind: "lines" | "strings";
|
||||
/** the list the run walks, as the editor shows it */
|
||||
module: string;
|
||||
q: string;
|
||||
states: RunState[];
|
||||
batch: number;
|
||||
}
|
||||
|
||||
export interface TranslationTask {
|
||||
id: number;
|
||||
versionId: string;
|
||||
lang: string;
|
||||
title: string;
|
||||
kind: "lines" | "strings";
|
||||
status: "running" | "stopping" | "done" | "stopped" | "failed";
|
||||
progress: Progress;
|
||||
/** lines of the page being translated that are not saved yet */
|
||||
keys: string[];
|
||||
/** the first line of that page: moves when the run moves on */
|
||||
cursor: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** What the open editor needs to show a page of results at once. */
|
||||
export interface FillEvent {
|
||||
versionId: string;
|
||||
lang: string;
|
||||
kind: "lines" | "strings";
|
||||
results: { key: string; text: string }[];
|
||||
stats: Partial<LangStats>;
|
||||
}
|
||||
|
||||
interface RunApi {
|
||||
task: TranslationTask | null;
|
||||
/** a run is going: no other can start */
|
||||
busy: boolean;
|
||||
start: (spec: RunSpec) => boolean;
|
||||
stop: () => void;
|
||||
dismiss: () => void;
|
||||
subscribe: (listener: (event: FillEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
const RunContext = createContext<RunApi | null>(null);
|
||||
const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
|
||||
|
||||
export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
||||
const [task, setTask] = useState<TranslationTask | null>(null);
|
||||
const control = useRef<AbortController | null>(null);
|
||||
const listeners = useRef(new Set<(event: FillEvent) => void>());
|
||||
const sequence = useRef(0);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
const patch = useCallback((id: number, change: Partial<TranslationTask> | ((t: TranslationTask) => Partial<TranslationTask>)) => {
|
||||
setTask((t) => (t && t.id === id ? { ...t, ...(typeof change === "function" ? change(t) : change) } : t));
|
||||
}, []);
|
||||
|
||||
const start = useCallback((spec: RunSpec) => {
|
||||
if (busyRef.current) return false;
|
||||
busyRef.current = true;
|
||||
const id = ++sequence.current;
|
||||
const ctl = new AbortController();
|
||||
control.current = ctl;
|
||||
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
|
||||
progress: EMPTY, keys: [], cursor: "", error: "" });
|
||||
|
||||
const url = (state: RunState, offset: number, limit: number) =>
|
||||
`/api/versions/${spec.versionId}/i18n/${spec.lang}/entries?${new URLSearchParams({
|
||||
kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`;
|
||||
|
||||
void (async () => {
|
||||
const base = { total: 0, done: 0, failed: 0 };
|
||||
let last: Progress = EMPTY;
|
||||
try {
|
||||
for (const state of spec.states) {
|
||||
const outcome = await runTranslation({
|
||||
client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal,
|
||||
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(url(state, skip, limit)),
|
||||
onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }),
|
||||
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/${spec.versionId}/i18n/${spec.lang}`,
|
||||
spec.kind === "strings" ? { strings: changes } : { lines: changes });
|
||||
const done = new Set(results.map((r) => r.key));
|
||||
patch(id, (t) => ({ keys: t.keys.filter((k) => !done.has(k)) }));
|
||||
for (const listener of listeners.current) {
|
||||
listener({ versionId: spec.versionId, lang: spec.lang, kind: spec.kind, results, stats });
|
||||
}
|
||||
},
|
||||
onProgress: (p) => {
|
||||
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
|
||||
patch(id, { progress: last });
|
||||
},
|
||||
});
|
||||
Object.assign(base, { total: last.total, done: last.done, failed: last.failed });
|
||||
if (outcome === "stopped") break;
|
||||
}
|
||||
patch(id, { status: ctl.signal.aborted ? "stopped" : "done", keys: [] });
|
||||
} catch (e) {
|
||||
patch(id, { status: "failed", error: (e as Error).message, keys: [] });
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
control.current = null;
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}, [patch]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
control.current?.abort();
|
||||
setTask((t) => (t && t.status === "running" ? { ...t, status: "stopping" } : t));
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => setTask((t) => (t && (t.status === "running" || t.status === "stopping") ? t : null)), []);
|
||||
|
||||
const subscribe = useCallback((listener: (event: FillEvent) => void) => {
|
||||
listeners.current.add(listener);
|
||||
return () => void listeners.current.delete(listener);
|
||||
}, []);
|
||||
|
||||
const busy = task?.status === "running" || task?.status === "stopping";
|
||||
|
||||
// closing the tab would end the run: ask first
|
||||
useEffect(() => {
|
||||
if (!busy) return;
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [busy]);
|
||||
|
||||
const value = useMemo(() => ({ task, busy: !!busy, start, stop, dismiss, subscribe }), [task, busy, start, stop, dismiss, subscribe]);
|
||||
return <RunContext.Provider value={value}>{children}</RunContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTranslationRun(): RunApi {
|
||||
const ctx = useContext(RunContext);
|
||||
if (!ctx) throw new Error("TranslationRunProvider is missing");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function statusText(task: TranslationTask): string {
|
||||
const p = task.progress;
|
||||
const count = `Переведено ${p.done} из ${p.total}${p.failed ? ` · ошибок ${p.failed}` : ""}`;
|
||||
switch (task.status) {
|
||||
case "running": return p.total ? count : "Готовлю перевод…";
|
||||
case "stopping": return "Останавливаю…";
|
||||
case "stopped": return `Остановлено. ${count}`;
|
||||
case "done": return `Готово. ${count}`;
|
||||
default: return "Перевод прервался";
|
||||
}
|
||||
}
|
||||
|
||||
/** The card of the run in the floating window. */
|
||||
export function TranslationProgress() {
|
||||
const { task, stop, dismiss } = useTranslationRun();
|
||||
if (!task) return null;
|
||||
const { progress: p } = task;
|
||||
const pct = p.total ? Math.min(100, Math.round(((p.done + p.failed) / p.total) * 100)) : 0;
|
||||
const active = task.status === "running" || task.status === "stopping";
|
||||
const text = statusText(task);
|
||||
return (
|
||||
<section className="upload-progress card" aria-label="Машинный перевод">
|
||||
<h2>Машинный перевод</h2>
|
||||
<div className="upload-task">
|
||||
<div className="upload-task-heading">
|
||||
<b>{task.title}</b>
|
||||
{!active && <button className="btn btn-ghost btn-icon" onClick={dismiss} aria-label="Убрать"><Icon name="x" size={16} /></button>}
|
||||
</div>
|
||||
<div className="muted small">{task.lang}{p.model ? ` · модель: ${p.model}` : ""}</div>
|
||||
{task.status !== "failed" && (
|
||||
<div className="progress" role="progressbar" aria-label="Машинный перевод" aria-valuemin={0} aria-valuemax={100}
|
||||
aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}>
|
||||
<div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} />
|
||||
</div>
|
||||
)}
|
||||
<p className="small" role="status">{text}</p>
|
||||
{task.error && <ErrorNote>{task.error}</ErrorNote>}
|
||||
{p.failed > 0 && p.lastError && <p className="muted small">Последняя ошибка: {p.lastError}</p>}
|
||||
<div className="actions">
|
||||
{active && <button className="btn btn-sm" disabled={task.status === "stopping"} onClick={stop}><Icon name="x" size={16} />Остановить</button>}
|
||||
<Link to={`/versions/${task.versionId}/translate?lang=${encodeURIComponent(task.lang)}${task.kind === "strings" ? "&tab=strings" : ""}`}
|
||||
className="small">Открыть редактор</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export interface Session {
|
||||
setPref: <K extends keyof Prefs>(key: K, value: Prefs[K]) => void;
|
||||
instructions: string;
|
||||
setInstructions: (value: string) => void;
|
||||
/** the game and version, as the floating card names the run */
|
||||
title: string;
|
||||
/** creates or updates the translator project of this game and language, returns its id */
|
||||
project: () => Promise<string>;
|
||||
/** translates one line; throws with the reason when the model could not */
|
||||
@@ -74,6 +76,7 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver
|
||||
const [instructions, setInstr] = useState("");
|
||||
const game = useRef<Game | null>(null);
|
||||
const gameId = version?.game_id;
|
||||
const [gameTitle, setGameTitle] = useState("");
|
||||
|
||||
const setPref = useCallback(<K extends keyof Prefs>(key: K, value: Prefs[K]) => {
|
||||
setPrefs((p) => {
|
||||
@@ -104,6 +107,14 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver
|
||||
if (read(USED_KEY)) void connect();
|
||||
}, [connect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameId === undefined) return;
|
||||
api.get<Game>(`/api/games/${gameId}`).then((g) => {
|
||||
game.current = g;
|
||||
setGameTitle(g.title);
|
||||
}).catch(() => {});
|
||||
}, [gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameId !== undefined) setInstr(read(`pg.translator.instructions.${gameId}`));
|
||||
}, [gameId]);
|
||||
@@ -157,6 +168,6 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver
|
||||
}, [client, project]);
|
||||
|
||||
return {
|
||||
client, link, ready, connect, providers, models, prefs, setPref, instructions, setInstructions, project, translateOne,
|
||||
client, link, ready, connect, title: [gameTitle, version?.name].filter(Boolean).join(" · "),providers, models, prefs, setPref, instructions, setInstructions, project, translateOne,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user