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>
202 lines
11 KiB
TypeScript
202 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|