fix: resume interrupted translations without losing saved progress

This commit is contained in:
mrleo1nid committed 2026-10-27 04:41:17 +03:00
1 parent a8902deaf7
commit e14184435c
6 files changed
+321 -44

No files matched your search

+17 -2
View File
@@ -222,8 +222,11 @@ translate russian strings:
записывается в `localStorage` (`pg.translation.run`: что переводится, где записывается в `localStorage` (`pg.translation.run`: что переводится, где
остановились, какое задание отправлено в переводчик). После обновления страницы остановились, какое задание отправлено в переводчик). После обновления страницы
сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт
дальше, а карточка и бейджи снова обновляются. Запись старше 30 минут не дальше, а карточка и бейджи снова обновляются. После ошибки или остановки
подхватывается; карточка и панель редактора предлагают «Возобновить»: сохранённые строки
остаются на месте, непереведённые (включая прошлые ошибки) повторяются.
Запись старше 30 минут тоже доступна для возобновления, но сама не запускается.
Состояние удаляется после успешного завершения или кнопкой «Убрать»;
* автоперевод идёт по списку так, как его видит пользователь: текущая * автоперевод идёт по списку так, как его видит пользователь: текущая
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз. вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40; Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
@@ -265,6 +268,18 @@ translate russian strings:
цикл перевода, `MachineTranslate.tsx` — панель, `TranslatorSetup.tsx` — цикл перевода, `MachineTranslate.tsx` — панель, `TranslatorSetup.tsx` —
страница настройки); проверки в `frontend/tests/translator-run.test.mjs`. страница настройки); проверки в `frontend/tests/translator-run.test.mjs`.
Сообщение `Model output was truncated or filtered` приходит от local-translator,
когда LM Studio возвращает `finish_reason=length` (исчерпан лимит ответа) либо
`content_filter`. По одному этому тексту отличить причины нельзя: для диагностики
нужны метрики вызова переводчика. Пачка ограничивается отдельно по числу строк
и по символам; лимит выходных токенов задаётся в конфигурации провайдера
local-translator. Повтор после такой ошибки использует вдвое меньшую пачку
(минимум одну строку). Успешный повтор убирает устаревшее сообщение ошибки модели.
Сбой получения строк или сохранения в библиотеку показывается отдельно с этапом
и HTTP-кодом, если сервер не вернул текст ошибки. Готовые результаты задания
и ключи уже сохранённых строк остаются в контрольной точке: возобновление не
отправляет готовое задание заново и не перезаписывает эти строки.
Ограничения: перевод идёт, пока открыта страница редактора; контекст Ограничения: перевод идёт, пока открыта страница редактора; контекст
(кто говорит, соседние реплики) модели пока не передаётся, только текст строки; (кто говорит, соседние реплики) модели пока не передаётся, только текст строки;
Safari может запрещать https-странице обращаться к локальному http. Safari может запрещать https-странице обращаться к локальному http.
@@ -175,6 +175,9 @@ export function MachineTranslate({ session, versionId, lang, title, view, follow
</p> </p>
<div className="actions"> <div className="actions">
{mine && task?.resumable && !run.busy && (
<button className="btn" onClick={() => void run.resume()} disabled={preparing}><Icon name="play" size={18} />Возобновить</button>
)}
{running ? ( {running ? (
<button className="btn" onClick={run.stop} disabled={task?.status === "stopping"}><Icon name="x" size={18} />Остановить</button> <button className="btn" onClick={run.stop} disabled={task?.status === "stopping"}><Icon name="x" size={18} />Остановить</button>
) : ( ) : (
+20 -8
View File
@@ -47,7 +47,7 @@ export interface RunOptions {
/** where an interrupted run stood: the counters and how many lines to skip */ /** where an interrupted run stood: the counters and how many lines to skip */
initial?: { total: number; done: number; failed: number; skip: number }; initial?: { total: number; done: number; failed: number; skip: number };
/** the job that was being translated when the run was interrupted, and the lines it carries */ /** the job that was being translated when the run was interrupted, and the lines it carries */
resume?: { jobId: string; keys: string[] }; resume?: { jobId: string; keys: string[]; savedKeys?: string[] };
/** a page was handed to the translator (`jobId`) or fully handled (`null`): enough to continue from here */ /** a page was handed to the translator (`jobId`) or fully handled (`null`): enough to continue from here */
onCheckpoint?: (cp: Checkpoint) => void; onCheckpoint?: (cp: Checkpoint) => void;
} }
@@ -59,6 +59,8 @@ export interface Checkpoint {
total: number; total: number;
done: number; done: number;
failed: number; failed: number;
/** Results already committed from this job; do not overwrite them on recovery. */
savedKeys?: string[];
} }
export interface Progress { export interface Progress {
@@ -97,11 +99,13 @@ export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel"
try { try {
job = await opts.client.job(job.id, opts.signal); job = await opts.client.job(job.id, opts.signal);
errors = 0; errors = 0;
await onPoll?.(job);
} catch (e) { } catch (e) {
if (opts.signal.aborted) return stop(); if (opts.signal.aborted) return stop();
if (++errors >= 4) throw e; if (++errors >= 4) throw e;
continue;
} }
// A library save failure must keep the checkpoint intact, not consume a polling retry.
await onPoll?.(job);
} }
return job; return job;
} }
@@ -113,20 +117,25 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
const emit = () => opts.onProgress({ ...progress }); const emit = () => opts.onProgress({ ...progress });
let skip = start?.skip ?? 0; let skip = start?.skip ?? 0;
let resume = opts.resume; let resume = opts.resume;
const checkpoint = (jobId: string | null, keys: string[]) => const checkpoint = (jobId: string | null, keys: string[], savedKeys: string[] = []) =>
opts.onCheckpoint?.({ jobId, keys, skip, total: progress.total, done: progress.done, failed: progress.failed }); opts.onCheckpoint?.({ jobId, keys, savedKeys, skip, total: progress.total, done: progress.done, failed: progress.failed });
while (!opts.signal.aborted) { while (!opts.signal.aborted) {
let items: Source[]; let items: Source[];
let job: Job; let job: Job;
let committed: string[] = [];
if (resume) { if (resume) {
// the page was already sent before the page was reloaded: pick its result up // the page was already sent before the page was reloaded: pick its result up
const taken = resume; const taken = resume;
resume = undefined; resume = undefined;
const known = await opts.client.job(taken.jobId, opts.signal).catch(() => null); const known = await opts.client.job(taken.jobId, opts.signal).catch((error) => {
if (error?.status === 404) return null;
throw error;
});
// the translator has forgotten it: the lines are still untranslated and come with the next page // the translator has forgotten it: the lines are still untranslated and come with the next page
if (!known) continue; if (!known) continue;
job = known; job = known;
committed = taken.savedKeys ?? [];
items = taken.keys.map((key, i) => ({ key, source: job.items[i]?.source ?? "" })); items = taken.keys.map((key, i) => ({ key, source: job.items[i]?.source ?? "" }));
opts.onPage?.(items); opts.onPage?.(items);
} else { } else {
@@ -147,7 +156,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
} }
// Lines are saved as the translator finishes them, not when the whole page is over: a slow // Lines are saved as the translator finishes them, not when the whole page is over: a slow
// model then shows progress at once, and a stop or a crash loses almost nothing. // model then shows progress at once, and a stop or a crash loses almost nothing.
const saved = new Set<number>(); const saved = new Set<number>(items.flatMap((item, i) => committed.includes(item.key) ? [i] : []));
// lines whose tags sit inside words were sent without them: they need a look afterwards // lines whose tags sit inside words were sent without them: they need a look afterwards
const plain = new Set<number>(); const plain = new Set<number>();
items.forEach((it, i) => { items.forEach((it, i) => {
@@ -161,7 +170,6 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
const index = at(it.id); const index = at(it.id);
const src = items[index]; const src = items[index];
if (!saved.has(index) && it.status === "completed" && it.translation?.trim() && src) { if (!saved.has(index) && it.status === "completed" && it.translation?.trim() && src) {
saved.add(index);
ready.push(fuzzy || plain.has(index) ? { key: src.key, text: it.translation, state: "fuzzy" } : { key: src.key, text: it.translation }); ready.push(fuzzy || plain.has(index) ? { key: src.key, text: it.translation, state: "fuzzy" } : { key: src.key, text: it.translation });
} else if (it.error && it.status !== "completed") { } else if (it.error && it.status !== "completed") {
// the translator is retrying this line: say why, so a slow page is not a silent one // the translator is retrying this line: say why, so a slow page is not a silent one
@@ -169,9 +177,13 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
} }
} }
progress.model = current.model ?? progress.model; progress.model = current.model ?? progress.model;
if (!progress.failed && current.items.every((item) => item.status === "completed")) progress.lastError = "";
if (ready.length) { if (ready.length) {
await opts.save(ready); await opts.save(ready);
const keys = new Set(ready.map((item) => item.key));
items.forEach((item, i) => { if (keys.has(item.key)) saved.add(i); });
progress.done += ready.length; progress.done += ready.length;
checkpoint(job.id, items.map((item) => item.key), [...saved].map((i) => items[i].key));
} }
emit(); emit();
}; };
@@ -199,7 +211,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
emit(); emit();
checkpoint(null, []); checkpoint(null, []);
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание"); if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
if (job.status === "cancelled") break; if (job.status === "cancelled") return "stopped";
} }
emit(); emit();
return opts.signal.aborted ? "stopped" : "finished"; return opts.signal.aborted ? "stopped" : "finished";
+97 -32
View File
@@ -14,7 +14,7 @@ import { runTranslation, type Checkpoint, type Progress, type Result, type Sourc
const PAGE = 40; const PAGE = 40;
const SAVED_KEY = "pg.translation.run"; const SAVED_KEY = "pg.translation.run";
const LOCK = "pg.translation.run"; const LOCK = "pg.translation.run";
/** a run last heard of longer ago than this is not picked up again */ /** Older runs remain available for manual resumption, without starting by themselves. */
const FRESH_MS = 30 * 60 * 1000; const FRESH_MS = 30 * 60 * 1000;
export type RunState = "untranslated" | "outdated"; export type RunState = "untranslated" | "outdated";
@@ -47,6 +47,9 @@ interface SavedRun {
before: { total: number; done: number; failed: number }; before: { total: number; done: number; failed: number };
checkpoint: Checkpoint | null; checkpoint: Checkpoint | null;
at: number; at: number;
status?: TranslationTask["status"];
progress?: Progress;
error?: string;
} }
export interface TranslationTask { export interface TranslationTask {
@@ -62,6 +65,7 @@ export interface TranslationTask {
/** the first line of that page: moves when the run moves on */ /** the first line of that page: moves when the run moves on */
cursor: string; cursor: string;
error: string; error: string;
resumable?: boolean;
} }
/** What the open editor needs to show a page of results at once. */ /** What the open editor needs to show a page of results at once. */
@@ -80,6 +84,7 @@ interface RunApi {
/** `false` when another run (here or in another tab) already holds the translator */ /** `false` when another run (here or in another tab) already holds the translator */
start: (spec: RunSpec) => Promise<boolean>; start: (spec: RunSpec) => Promise<boolean>;
stop: () => void; stop: () => void;
resume: () => Promise<boolean>;
dismiss: () => void; dismiss: () => void;
subscribe: (listener: (event: FillEvent) => void) => () => void; subscribe: (listener: (event: FillEvent) => void) => () => void;
} }
@@ -87,10 +92,16 @@ interface RunApi {
const RunContext = createContext<RunApi | null>(null); const RunContext = createContext<RunApi | null>(null);
const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null }; const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
function failureMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error ?? "");
const status = (error as { status?: number } | null)?.status;
return message || (status ? `HTTP ${status}` : "Неизвестная ошибка");
}
function loadSaved(): SavedRun | null { function loadSaved(): SavedRun | null {
try { try {
const saved = JSON.parse(localStorage.getItem(SAVED_KEY) ?? "null") as SavedRun | null; const saved = JSON.parse(localStorage.getItem(SAVED_KEY) ?? "null") as SavedRun | null;
return saved && saved.info && Date.now() - saved.at < FRESH_MS ? saved : null; return saved && saved.info ? saved : null;
} catch { } catch {
return null; return null;
} }
@@ -126,6 +137,11 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
const listeners = useRef(new Set<(event: FillEvent) => void>()); const listeners = useRef(new Set<(event: FillEvent) => void>());
const sequence = useRef(0); const sequence = useRef(0);
const busyRef = useRef(false); const busyRef = useRef(false);
const recovery = useRef<SavedRun | null>(null);
const remember = useCallback((saved: SavedRun | null) => {
recovery.current = saved;
store(saved);
}, []);
const patch = useCallback((id: number, change: Partial<TranslationTask> | ((t: TranslationTask) => Partial<TranslationTask>)) => { 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)); setTask((t) => (t && t.id === id ? { ...t, ...(typeof change === "function" ? change(t) : change) } : t));
@@ -139,10 +155,8 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
control.current = ctl; control.current = ctl;
const before = saved?.before ?? { total: 0, done: 0, failed: 0 }; const before = saved?.before ?? { total: 0, done: 0, failed: 0 };
const resumed = saved?.checkpoint; const resumed = saved?.checkpoint;
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running", const initialProgress = { ...EMPTY, total: before.total + (resumed?.total ?? 0), done: before.done + (resumed?.done ?? 0),
progress: { ...EMPTY, total: before.total + (resumed?.total ?? 0), done: before.done + (resumed?.done ?? 0), failed: before.failed + (resumed?.failed ?? 0) };
failed: before.failed + (resumed?.failed ?? 0) },
keys: [], cursor: "", error: "" });
const info: RunInfo = { const info: RunInfo = {
projectId: spec.projectId, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, projectId: spec.projectId, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind,
@@ -153,10 +167,12 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`; kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`;
const work = async () => { const work = async () => {
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
progress: initialProgress, keys: [], cursor: "", error: "" });
// written only once this tab owns the translator, so it never overwrites another tab's record // written only once this tab owns the translator, so it never overwrites another tab's record
store({ info, base: spec.client.base, phase: saved?.phase ?? 0, before, checkpoint: resumed ?? null, at: Date.now() }); remember({ info, base: spec.client.base, phase: saved?.phase ?? 0, before, checkpoint: resumed ?? null, at: Date.now(), status: "running" });
const base = { ...before }; const base = { ...before };
let last: Progress = EMPTY; let last: Progress = initialProgress;
try { try {
for (let phase = saved?.phase ?? 0; phase < spec.states.length; phase++) { for (let phase = saved?.phase ?? 0; phase < spec.states.length; phase++) {
const state = spec.states[phase]; const state = spec.states[phase];
@@ -164,14 +180,20 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
const outcome = await runTranslation({ const outcome = await runTranslation({
client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal, client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal,
initial: again ? { total: again.total, done: again.done, failed: again.failed, skip: again.skip } : undefined, initial: again ? { total: again.total, done: again.done, failed: again.failed, skip: again.skip } : undefined,
resume: again?.jobId ? { jobId: again.jobId, keys: again.keys } : undefined, resume: again?.jobId ? { jobId: again.jobId, keys: again.keys, savedKeys: again.savedKeys } : undefined,
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(url(state, skip, limit)), fetchPage: async (skip, limit) => {
onCheckpoint: (checkpoint) => store({ info, base: spec.client.base, phase, before: { ...base }, checkpoint, at: Date.now() }), try { return await api.get<{ total: number; items: Source[] }>(url(state, skip, limit)); }
catch (error) { throw new Error(`Не удалось получить строки для перевода: ${failureMessage(error)}`); }
},
onCheckpoint: (checkpoint) => remember({ info, base: spec.client.base, phase, before: { ...base }, checkpoint, at: Date.now(), status: "running" }),
onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }), onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }),
save: async (results) => { save: async (results) => {
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: r.state ?? "machine" }])); const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: r.state ?? "machine" }]));
const stats = await api.put<Partial<LangStats>>(`/api/versions/${spec.versionId}/i18n/${spec.lang}`, let stats: Partial<LangStats>;
spec.kind === "strings" ? { strings: changes } : { lines: changes }); try {
stats = await api.put<Partial<LangStats>>(`/api/versions/${spec.versionId}/i18n/${spec.lang}`,
spec.kind === "strings" ? { strings: changes } : { lines: changes });
} catch (error) { throw new Error(`Не удалось сохранить перевод: ${failureMessage(error)}`); }
const done = new Set(results.map((r) => r.key)); const done = new Set(results.map((r) => r.key));
patch(id, (t) => ({ keys: t.keys.filter((k) => !done.has(k)) })); patch(id, (t) => ({ keys: t.keys.filter((k) => !done.has(k)) }));
for (const listener of listeners.current) { for (const listener of listeners.current) {
@@ -181,16 +203,26 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
onProgress: (p) => { onProgress: (p) => {
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed }; last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
patch(id, { progress: last }); patch(id, { progress: last });
if (recovery.current) remember({ ...recovery.current, progress: last, at: Date.now() });
}, },
}); });
Object.assign(base, { total: last.total, done: last.done, failed: last.failed }); Object.assign(base, { total: last.total, done: last.done, failed: last.failed });
if (outcome === "stopped") break; if (outcome === "stopped") {
patch(id, { status: "stopped", keys: [], resumable: true });
if (recovery.current) remember({ ...recovery.current, status: "stopped", progress: last });
return;
}
} }
patch(id, { status: ctl.signal.aborted ? "stopped" : "done", keys: [] }); const status = ctl.signal.aborted ? "stopped" : "done";
const resumable = status === "stopped" || last.failed > 0;
patch(id, { status, keys: [], resumable });
if (resumable && recovery.current) remember({ ...recovery.current, status, progress: last });
else remember(null);
} catch (e) { } catch (e) {
patch(id, { status: "failed", error: (e as Error).message, keys: [] }); const error = failureMessage(e);
patch(id, { status: "failed", error, keys: [], resumable: true });
if (recovery.current) remember({ ...recovery.current, status: "failed", error, progress: last });
} finally { } finally {
store(null);
busyRef.current = false; busyRef.current = false;
control.current = null; control.current = null;
} }
@@ -204,19 +236,45 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
setTask((t) => (t && t.id === id ? null : t)); setTask((t) => (t && t.id === id ? null : t));
} }
return got; return got;
}, [patch]); }, [patch, remember]);
const start = useCallback((spec: RunSpec) => launch(spec), [launch]); const start = useCallback((spec: RunSpec) => launch(spec), [launch]);
const resume = useCallback(async () => {
const saved = recovery.current;
if (!saved || busyRef.current) return false;
const p = saved.progress ?? { ...EMPTY, done: saved.before.done + (saved.checkpoint?.done ?? 0),
total: saved.before.total + (saved.checkpoint?.total ?? 0) };
// Revisit failed rows from every phase. Already saved rows no longer match the filters.
const batch = /output was truncated|content_filter|finish_reason.*length/i.test(`${p.lastError} ${saved.error ?? ""}`)
? Math.max(1, Math.floor(saved.info.batch / 2)) : saved.info.batch;
const next: SavedRun = { ...saved, info: { ...saved.info, batch }, phase: 0,
before: { total: p.done, done: p.done, failed: 0 },
checkpoint: { jobId: saved.checkpoint?.jobId ?? null, keys: saved.checkpoint?.keys ?? [],
savedKeys: saved.checkpoint?.savedKeys, skip: 0, total: Math.max(0, p.total - p.done), done: 0, failed: 0 } };
const started = await launch({ ...next.info, client: new TranslatorClient(next.base) }, next);
if (!started) setTask((task) => task ? { ...task, error: "Другой перевод уже идёт в другой вкладке. Дождитесь его или остановите." } : task);
return started;
}, [launch]);
// A reload ends the page's loop, but the translator goes on with its page: continue from the saved point. // A reload ends the page's loop, but the translator goes on with its page: continue from the saved point.
useEffect(() => { useEffect(() => {
const saved = loadSaved(); const saved = loadSaved();
if (!saved) return; if (!saved) return;
const resume = () => launch({ ...saved.info, client: new TranslatorClient(saved.base) }, saved); recovery.current = saved;
if ((saved.status && saved.status !== "running") || Date.now() - saved.at >= FRESH_MS) {
setTask({ id: ++sequence.current, versionId: saved.info.versionId, lang: saved.info.lang, title: saved.info.title,
kind: saved.info.kind, status: saved.status === "failed" ? "failed" : "stopped", resumable: true,
progress: saved.progress ?? { ...EMPTY, total: saved.before.total + (saved.checkpoint?.total ?? 0),
done: saved.before.done + (saved.checkpoint?.done ?? 0), failed: saved.before.failed + (saved.checkpoint?.failed ?? 0) },
keys: [], cursor: "", error: saved.error ?? "" });
return;
}
const reload = () => launch({ ...saved.info, client: new TranslatorClient(saved.base) }, saved);
let retry: ReturnType<typeof setTimeout> | undefined; let retry: ReturnType<typeof setTimeout> | undefined;
// the page that was reloaded may still be letting go of its lock: look once more shortly // the page that was reloaded may still be letting go of its lock: look once more shortly
void resume().then((got) => { void reload().then((got) => {
if (!got && !busyRef.current) retry = setTimeout(() => void resume(), 2000); if (!got && !busyRef.current) retry = setTimeout(() => void reload(), 2000);
}); });
return () => clearTimeout(retry); return () => clearTimeout(retry);
}, [launch]); }, [launch]);
@@ -226,7 +284,11 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
setTask((t) => (t && t.status === "running" ? { ...t, status: "stopping" } : t)); 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 dismiss = useCallback(() => {
if (busyRef.current) return;
remember(null);
setTask(null);
}, [remember]);
const subscribe = useCallback((listener: (event: FillEvent) => void) => { const subscribe = useCallback((listener: (event: FillEvent) => void) => {
listeners.current.add(listener); listeners.current.add(listener);
@@ -243,7 +305,7 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn);
}, [busy]); }, [busy]);
const value = useMemo(() => ({ task, busy: !!busy, start, stop, dismiss, subscribe }), [task, busy, start, stop, dismiss, subscribe]); const value = useMemo(() => ({ task, busy: !!busy, start, stop, resume, dismiss, subscribe }), [task, busy, start, stop, resume, dismiss, subscribe]);
return <RunContext.Provider value={value}>{children}</RunContext.Provider>; return <RunContext.Provider value={value}>{children}</RunContext.Provider>;
} }
@@ -261,13 +323,13 @@ export function statusText(task: TranslationTask): string {
case "stopping": return "Останавливаю…"; case "stopping": return "Останавливаю…";
case "stopped": return `Остановлено. ${count}`; case "stopped": return `Остановлено. ${count}`;
case "done": return `Готово. ${count}`; case "done": return `Готово. ${count}`;
default: return "Перевод прервался"; default: return `Перевод прервался. ${count}`;
} }
} }
/** The card of the run in the floating window. */ /** The card of the run in the floating window. */
export function TranslationProgress() { export function TranslationProgress() {
const { task, stop, dismiss } = useTranslationRun(); const { task, stop, resume, dismiss } = useTranslationRun();
if (!task) return null; if (!task) return null;
const { progress: p } = task; const { progress: p } = task;
const pct = p.total ? Math.min(100, Math.round(((p.done + p.failed) / p.total) * 100)) : 0; const pct = p.total ? Math.min(100, Math.round(((p.done + p.failed) / p.total) * 100)) : 0;
@@ -282,16 +344,19 @@ export function TranslationProgress() {
{!active && <button className="btn btn-ghost btn-icon" onClick={dismiss} aria-label="Убрать"><Icon name="x" size={16} /></button>} {!active && <button className="btn btn-ghost btn-icon" onClick={dismiss} aria-label="Убрать"><Icon name="x" size={16} /></button>}
</div> </div>
<div className="muted small">{task.lang}{p.model ? ` · модель: ${p.model}` : ""}</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}
<div className="progress" role="progressbar" aria-label="Машинный перевод" aria-valuemin={0} aria-valuemax={100} aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}>
aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}> <div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} />
<div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} /> </div>
</div>
)}
<p className="small" role="status">{text}</p> <p className="small" role="status">{text}</p>
{task.error && <ErrorNote>{task.error}</ErrorNote>} {task.error && <ErrorNote>{task.error}</ErrorNote>}
{p.lastError && <p className="muted small">{p.failed > 0 ? "Последняя ошибка" : "Модель повторяет попытку"}: {p.lastError}</p>} {p.lastError && <p className="muted small">{active && !p.failed ? "Модель повторяет попытку" : "Последняя ошибка модели"}: {p.lastError}</p>}
{/output was truncated|content_filter/i.test(p.lastError || task.error) && (
<p className="muted small">Ответ модели обрезан лимитом токенов или отклонён фильтром переводчика.
При возобновлении размер пачки будет уменьшен. Готовые переводы сохранены.</p>
)}
<div className="actions"> <div className="actions">
{!active && task.resumable && <button className="btn btn-sm" onClick={() => void resume()}><Icon name="play" size={16} />Возобновить</button>}
{active && <button className="btn btn-sm" disabled={task.status === "stopping"} onClick={stop}><Icon name="x" size={16} />Остановить</button>} {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" : ""}`} <Link to={`/versions/${task.versionId}/translate?lang=${encodeURIComponent(task.lang)}${task.kind === "strings" ? "&tab=strings" : ""}`}
className="small">Открыть редактор</Link> className="small">Открыть редактор</Link>
+152
View File
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
import { parseHTML } from "linkedom";
const { window, document } = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { userAgent: "test" } });
// Bundled React's act() uses its browser fallback; avoid leaving native MessagePorts open.
globalThis.MessageChannel = class {
port1 = { onmessage: null };
port2 = { postMessage: () => setImmediate(() => this.port1.onmessage?.()) };
};
const storage = new Map();
globalThis.localStorage = {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key),
};
const compiled = await build({ stdin: { contents: `
export {createElement, act} from 'react';
export {createRoot} from 'react-dom/client';
export {MemoryRouter} from 'react-router-dom';
export * from './translator/runner';
export {TranslatorClient} from './translator/client';`,
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { createElement: h, act, createRoot, MemoryRouter, TranslationRunProvider, TranslationProgress,
useTranslationRun, TranslatorClient } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
Error.stackTraceLimit = 2;
const respond = (data, status = 200) => new Response(JSON.stringify(data), { status });
const savedKey = "pg.translation.run";
function fixture({ failSave = false, broken = false, hold = false } = {}) {
storage.clear();
const rows = [{ key: "a", source: "Hello" }, { key: "b", source: "Bye" }];
const saved = new Map(), jobs = new Map(), sizes = [];
let firstSave = failSave;
globalThis.fetch = async (raw, init = {}) => {
const url = new URL(raw, "http://library");
if (url.pathname.endsWith("/entries")) {
const open = rows.filter((row) => !saved.has(row.key));
return respond({ total: open.length, items: open.slice(Number(url.searchParams.get("offset"))) });
}
if (url.pathname === "/api/versions/v/i18n/ru") {
if (firstSave) { firstSave = false; return respond({}, 503); }
const body = JSON.parse(init.body);
for (const [key, value] of Object.entries(body.lines)) saved.set(key, value.t);
return respond({});
}
if (url.pathname.endsWith("/jobs") && init.method === "POST") {
const body = JSON.parse(init.body);
sizes.push(body.batch_size);
const failed = broken && sizes.length === 1;
const running = hold && sizes.length === 1;
const job = { id: String(jobs.size), status: running ? "running" : failed ? "completed_with_errors" : "completed", model: "tomie-translator", error: null,
items: body.items.map((item, i) => ({ id: item.id, source: item.text,
status: running && i > 0 ? "pending" : failed ? "failed" : "completed", translation: failed || (running && i > 0) ? null : `RU ${item.text}`,
error: failed ? "Model output was truncated or filtered; reduce batch size or increase max_tokens" : null })) };
jobs.set(job.id, job);
return respond(job);
}
if (url.pathname.endsWith("/cancel")) return respond({ ...jobs.get(url.pathname.split("/").at(-2)), status: "cancelled" });
if (url.pathname.startsWith("/jobs/")) return respond(jobs.get(url.pathname.split("/").at(-1)));
throw new Error(`Unexpected request ${url.pathname}`);
};
let api, root;
const container = document.createElement("div");
document.body.append(container);
const Probe = () => { api = useTranslationRun(); return h(TranslationProgress); };
const mount = async () => {
root = createRoot(container);
await act(async () => root.render(h(MemoryRouter, null, h(TranslationRunProvider, null, h(Probe)))));
};
const settle = async (check) => {
const deadline = Date.now() + 3000;
while (!check()) {
if (Date.now() > deadline) throw new Error("Translation did not settle");
await act(async () => new Promise((resolve) => setTimeout(resolve, 5)));
}
};
return { saved, sizes, container, mount, settle, get api() { return api; },
unmount: async () => { await act(async () => root.unmount()); },
start: async () => { await act(async () => api.start({ client: new TranslatorClient("http://translator"),
projectId: "p", versionId: "v", lang: "ru", title: "MIST", kind: "lines", module: "", q: "", states: ["untranslated"], batch: 6 })); },
};
}
test("a failed save survives reload and Resume recovers the existing job", async () => {
const f = fixture({ failSave: true });
await f.mount();
try {
await f.start();
await f.settle(() => f.api.task?.status === "failed");
assert.match(f.container.textContent, /Не удалось сохранить перевод: HTTP 503/);
assert.match(f.container.textContent, /Возобновить/);
assert.ok(storage.has(savedKey));
await f.unmount();
await f.mount();
assert.equal(f.api.task.status, "failed");
await act(async () => f.api.resume());
await f.settle(() => f.api.task?.status === "done");
assert.equal(f.saved.size, 2);
assert.equal(f.api.task.progress.done, 2);
assert.deepEqual(f.sizes, [6], "a completed translator job is not submitted again");
assert.equal(storage.has(savedKey), false);
} finally { await f.unmount(); }
});
test("Stop retains completed rows and Resume sends only the remaining work", async () => {
const f = fixture({ hold: true });
await f.mount();
try {
await f.start();
await f.settle(() => f.sizes.length === 1);
await act(async () => f.api.stop());
await f.settle(() => f.api.task?.status === "stopped");
assert.equal(f.saved.size, 1);
assert.equal(f.api.task.progress.done, 1);
assert.ok(storage.has(savedKey));
await act(async () => f.api.resume());
await f.settle(() => f.api.task?.status === "done");
assert.equal(f.saved.size, 2);
assert.equal(f.api.task.progress.done, 2);
assert.equal(f.api.task.progress.total, 2);
assert.equal(storage.has(savedKey), false);
} finally { await f.unmount(); }
});
test("Resume retries failed rows with a smaller batch, including after an old checkpoint", async () => {
const f = fixture({ broken: true });
await f.mount();
try {
await f.start();
await f.settle(() => f.api.task?.status === "done");
assert.equal(f.api.task.progress.failed, 2);
assert.equal(f.api.task.resumable, true);
const snapshot = JSON.parse(storage.get(savedKey));
snapshot.at = Date.now() - 86400000;
storage.set(savedKey, JSON.stringify(snapshot));
await f.unmount();
await f.mount();
assert.equal(f.api.task.status, "stopped");
const button = [...f.container.querySelectorAll("button")].find((button) => button.textContent.includes("Возобновить"));
await act(async () => button.dispatchEvent(new window.Event("click", { bubbles: true })));
await f.settle(() => f.api.task?.status === "done");
assert.deepEqual(f.sizes, [6, 3]);
assert.equal(f.api.task.progress.done, 2);
assert.equal(f.api.task.progress.failed, 0);
assert.equal(f.saved.size, 2);
} finally { await f.unmount(); }
});
+32 -2
View File
@@ -160,7 +160,7 @@ test("a run that was interrupted picks up the page the translator finished meanw
test("a job the translator no longer knows is skipped and its lines come back with the list", async () => { test("a job the translator no longer knows is skipped and its lines come back with the list", async () => {
const server = fakeServer(["a", "b", "c"]); const server = fakeServer(["a", "b", "c"]);
const translator = { ...fakeTranslator(), async job() { throw new Error("404"); } }; const translator = { ...fakeTranslator(), async job() { throw Object.assign(new Error("404"), { status: 404 }); } };
// `job` is also used while waiting, but the resumed job fails at once, so nothing waits // `job` is also used while waiting, but the resumed job fails at once, so nothing waits
const created = []; const created = [];
const client = { ...translator, async createJob(...args) { const j = await fakeTranslator().createJob(...args); created.push(j); return j; } }; const client = { ...translator, async createJob(...args) { const j = await fakeTranslator().createJob(...args); created.push(j); return j; } };
@@ -172,7 +172,37 @@ test("a job the translator no longer knows is skipped and its lines come back wi
test("checkpoints say which page is out and when it is handled", async () => { test("checkpoints say which page is out and when it is handled", async () => {
const marks = []; const marks = [];
await run(fakeTranslator(), fakeServer(["a", "b", "c"]), { pageSize: 2, onCheckpoint: (c) => marks.push([c.jobId ? "sent" : "done", c.keys.length, c.skip]) }); await run(fakeTranslator(), fakeServer(["a", "b", "c"]), { pageSize: 2, onCheckpoint: (c) => marks.push([c.jobId ? "sent" : "done", c.keys.length, c.skip]) });
assert.deepEqual(marks, [["sent", 2, 0], ["done", 0, 0], ["sent", 1, 0], ["done", 0, 0]]); assert.deepEqual(marks, [["sent", 2, 0], ["sent", 2, 0], ["done", 0, 0], ["sent", 1, 0], ["sent", 1, 0], ["done", 0, 0]]);
});
test("a failed save keeps its results available for resuming instead of silently skipping them", async () => {
const translator = fakeTranslator();
let checkpoint;
const server = fakeServer(["a", "b"]);
await assert.rejects(run(translator, server, {
save: async () => { throw new Error("save offline"); },
onCheckpoint: (cp) => { checkpoint = cp; },
}), /save offline/);
assert.equal(checkpoint.jobId, "j0");
assert.equal(checkpoint.done, 0);
await run(translator, server, { initial: checkpoint, resume: checkpoint });
assert.deepEqual(server.saved, { k0: "RU a", k1: "RU b" });
assert.equal(translator.created.length, 1);
});
test("resuming a partially saved job does not overwrite its committed rows or count them twice", async () => {
const translator = fakeTranslator();
const server = fakeServer(["a", "b"]);
const job = await translator.createJob("p", [{ id: "0", text: "a" }, { id: "1", text: "b" }]);
server.saved.k0 = "Human edit";
const seen = [];
await run(translator, server, {
initial: { total: 2, done: 1, failed: 0, skip: 0 },
resume: { jobId: job.id, keys: ["k0", "k1"], savedKeys: ["k0"] },
onProgress: (p) => seen.push(p),
});
assert.deepEqual(server.saved, { k0: "Human edit", k1: "RU b" });
assert.equal(seen.at(-1).done, 2);
}); });
test("a slow page is saved line by line while it is still running", async () => { test("a slow page is saved line by line while it is still running", async () => {