feat: per-line translate buttons, auto-translate follows the visible list and fills rows live
Shared translator session, game description/genres as optional context, live header progress. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8d6bf4646
commit
5f6bd4508e
6 files changed
+316
-157
No files matched your search
+11
-2
@@ -213,8 +213,17 @@ translate russian strings:
|
|||||||
`PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий
|
`PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий
|
||||||
из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и
|
из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и
|
||||||
обновляется перед каждым запуском;
|
обновляется перед каждым запуском;
|
||||||
* строки берутся страницами по 40 с фильтром «не переведено» (и «устарело» по
|
* автоперевод идёт по списку так, как его видит пользователь: текущая
|
||||||
желанию), страница уходит одним заданием, готовое сразу сохраняется. Остановка
|
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
|
||||||
|
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
|
||||||
|
страница уходит одним заданием, готовое сразу сохраняется, появляется в
|
||||||
|
открытых строках и обновляет прогресс в шапке;
|
||||||
|
* у каждой строки есть кнопка «Перевести» (пока переводчик подключён): она
|
||||||
|
переводит одну строку с тем же контекстом и глоссарием и помечает её
|
||||||
|
«Машинный». Редактор ищет переводчик сам, только если он уже работал в этом
|
||||||
|
браузере, чтобы Chrome не спрашивал про локальную сеть без причины;
|
||||||
|
* флажок «Передавать описание и жанры игры как контекст» (по умолчанию включён)
|
||||||
|
добавляет описание и теги игры в контекст проекта; название игры уходит всегда; Остановка
|
||||||
отменяет задание в переводчике; повторный запуск продолжает с оставшегося.
|
отменяет задание в переводчике; повторный запуск продолжает с оставшегося.
|
||||||
Непереведённые из-за ошибки строки (например, модель повредила плейсхолдер)
|
Непереведённые из-за ошибки строки (например, модель повредила плейсхолдер)
|
||||||
пропускаются и видны в счётчике ошибок;
|
пропускаются и видны в счётчике ошибок;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { api, uploadRaw, type Version } from "../api";
|
|||||||
import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits";
|
import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { useUi } from "../ui/Ui";
|
import { useUi } from "../ui/Ui";
|
||||||
import { MachineTranslate } from "../translator/MachineTranslate";
|
import { MachineTranslate, type View } from "../translator/MachineTranslate";
|
||||||
|
import { useTranslatorSession, type Session } from "../translator/session";
|
||||||
import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts";
|
import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts";
|
||||||
import {
|
import {
|
||||||
FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE,
|
FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE,
|
||||||
@@ -23,6 +24,23 @@ export function TranslationEditor() {
|
|||||||
const [machine, setMachine] = useState(false);
|
const [machine, setMachine] = useState(false);
|
||||||
const lang = params.get("lang") ?? "";
|
const lang = params.get("lang") ?? "";
|
||||||
const tab = (params.get("tab") as Tab) ?? "lines";
|
const tab = (params.get("tab") as Tab) ?? "lines";
|
||||||
|
const session = useTranslatorSession(versionId, version, lang, ov);
|
||||||
|
// what the list shows now, so the automatic run goes through it in the same order
|
||||||
|
const [view, setView] = useState<View>({ kind: "lines", module: "", q: "" });
|
||||||
|
// lines translated by the automatic run, shown in the rows without reloading the list
|
||||||
|
const fills = useRef<Record<string, Record<string, string>>>({});
|
||||||
|
const [fillTick, setFillTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
fills.current = {};
|
||||||
|
}, [lang]);
|
||||||
|
const onFill = useCallback((kind: string, results: { key: string; text: string }[]) => {
|
||||||
|
const map = (fills.current[kind] ??= {});
|
||||||
|
for (const r of results) map[r.key] = r.text;
|
||||||
|
setFillTick((t) => t + 1);
|
||||||
|
}, []);
|
||||||
|
const onStats = useCallback((s: Partial<LangStats>) => {
|
||||||
|
setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) });
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
const setParam = (key: string, value: string) => {
|
const setParam = (key: string, value: string) => {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
@@ -196,9 +214,8 @@ export function TranslationEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{machine && ov.editable && stats && (
|
{machine && ov.editable && stats && (
|
||||||
<MachineTranslate key={lang} versionId={versionId!} version={version} lang={lang} ov={ov}
|
<MachineTranslate key={lang} session={session} versionId={versionId!} lang={lang} view={view}
|
||||||
onClose={() => setMachine(false)}
|
onFill={onFill} onStats={onStats} onClose={() => setMachine(false)} />
|
||||||
onChanged={() => void loadOverview().then(() => setParam("r", String(Date.now())))} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!ov.languages.length ? (
|
{!ov.languages.length ? (
|
||||||
@@ -214,15 +231,16 @@ export function TranslationEditor() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<EntriesView key={`${lang}-${tab}-${params.get("r") ?? ""}`} versionId={versionId!} lang={lang} tab={tab}
|
<EntriesView key={`${lang}-${tab}-${params.get("r") ?? ""}`} versionId={versionId!} lang={lang} tab={tab}
|
||||||
ov={ov} onStats={(s) => setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) })} />
|
ov={ov} onStats={onStats} session={session} fills={fills.current} fillTick={fillTick} onView={setView} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntriesView({ versionId, lang, tab, ov, onStats }: {
|
function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTick, onView }: {
|
||||||
versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial<LangStats>) => void;
|
versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial<LangStats>) => void;
|
||||||
|
session: Session; fills: Record<string, Record<string, string>>; fillTick: number; onView: (v: View) => void;
|
||||||
}) {
|
}) {
|
||||||
const ui = useUi();
|
const ui = useUi();
|
||||||
const [state, setState] = useState("todo");
|
const [state, setState] = useState("todo");
|
||||||
@@ -283,6 +301,22 @@ function EntriesView({ versionId, lang, tab, ov, onStats }: {
|
|||||||
}
|
}
|
||||||
}, [versionId, lang, tab, onStats, ui]);
|
}, [versionId, lang, tab, onStats, ui]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onView({ kind: tab, module, q });
|
||||||
|
}, [onView, tab, module, q]);
|
||||||
|
|
||||||
|
// show what the automatic run has translated, but never over a line the user has just edited
|
||||||
|
useEffect(() => {
|
||||||
|
const done = fills[tab];
|
||||||
|
if (!done) return;
|
||||||
|
setItems((list) => list?.map((it) => {
|
||||||
|
const t = done[it.key];
|
||||||
|
return t !== undefined && (it.state === "untranslated" || it.state === "outdated") && it.translation !== t
|
||||||
|
? { ...it, translation: t, state: "machine" as LineState } : it;
|
||||||
|
}) ?? null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fillTick]);
|
||||||
|
|
||||||
// don't lose edits when leaving the page
|
// don't lose edits when leaving the page
|
||||||
useEffect(() => () => void flush(), [flush]);
|
useEffect(() => () => void flush(), [flush]);
|
||||||
|
|
||||||
@@ -340,7 +374,7 @@ function EntriesView({ versionId, lang, tab, ov, onStats }: {
|
|||||||
<div className="tr-list">
|
<div className="tr-list">
|
||||||
{items.map((it, i) => (
|
{items.map((it, i) => (
|
||||||
<Row key={it.key} item={it} versionId={versionId} lang={lang} editable={ov.editable}
|
<Row key={it.key} item={it} versionId={versionId} lang={lang} editable={ov.editable}
|
||||||
canApprove={ov.canApprove} terms={terms}
|
canApprove={ov.canApprove} terms={terms} machine={session.ready ? session : null}
|
||||||
onChange={(t, s) => update(it.key, t, s)} onBlur={() => void flush()}
|
onChange={(t, s) => update(it.key, t, s)} onBlur={() => void flush()}
|
||||||
onNext={() => {
|
onNext={() => {
|
||||||
void flush();
|
void flush();
|
||||||
@@ -360,13 +394,29 @@ function EntriesView({ versionId, lang, tab, ov, onStats }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onBlur, onNext }: {
|
function Row({ item, versionId, lang, editable, canApprove, terms, machine, onChange, onBlur, onNext }: {
|
||||||
item: Item; versionId: string; lang: string; editable: boolean; canApprove: boolean; terms: GlossaryTerm[];
|
item: Item; versionId: string; lang: string; editable: boolean; canApprove: boolean; terms: GlossaryTerm[];
|
||||||
|
/** the connected translator: when set, the line can be translated by itself */
|
||||||
|
machine: Session | null;
|
||||||
onChange: (t: string, state?: LineState) => void; onBlur: () => void; onNext: () => void;
|
onChange: (t: string, state?: LineState) => void; onBlur: () => void; onNext: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const ui = useUi();
|
||||||
const [context, setContext] = useState(false);
|
const [context, setContext] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const translateHere = async () => {
|
||||||
|
if (!machine) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
onChange(await machine.translateOne(item.source), "machine");
|
||||||
|
} catch (e) {
|
||||||
|
ui.toast((e as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -418,6 +468,12 @@ function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onB
|
|||||||
}} />
|
}} />
|
||||||
{editable && (
|
{editable && (
|
||||||
<div className="tr-row-actions">
|
<div className="tr-row-actions">
|
||||||
|
{machine && (
|
||||||
|
<button className="btn btn-sm btn-ghost" disabled={busy} onClick={() => void translateHere()}
|
||||||
|
title="Перевести эту строку нейросетью на вашем компьютере">
|
||||||
|
<Icon name="globe" size={14} />{busy ? "Перевожу…" : item.translation ? "Перевести заново" : "Перевести"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!item.translation && (
|
{!item.translation && (
|
||||||
<button className="btn btn-sm btn-ghost" title="Скопировать оригинал" onClick={() => onChange(item.source)}>
|
<button className="btn btn-sm btn-ghost" title="Скопировать оригинал" onClick={() => onChange(item.source)}>
|
||||||
<Icon name="copy" size={14} />Оригинал
|
<Icon name="copy" size={14} />Оригинал
|
||||||
|
|||||||
@@ -1,61 +1,33 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { api, type Game, type Version } from "../api";
|
import { api } from "../api";
|
||||||
import type { GlossaryTerm, Overview } from "../pages/translation-types";
|
import type { LangStats } from "../pages/translation-types";
|
||||||
import { ErrorNote } from "../ui/Bits";
|
import { ErrorNote } from "../ui/Bits";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import {
|
|
||||||
TranslatorClient, languageName, normalizeUrl, savedUrl, type Link as Connection,
|
|
||||||
} from "./client";
|
|
||||||
import { runTranslation, type Progress, type Source } from "./run";
|
import { runTranslation, type Progress, type Source } from "./run";
|
||||||
|
import type { Session } from "./session";
|
||||||
import "./translator.css";
|
import "./translator.css";
|
||||||
|
|
||||||
const PAGE = 40;
|
const PAGE = 40;
|
||||||
const PREFS_KEY = "pg.translator.prefs";
|
|
||||||
|
|
||||||
interface Prefs {
|
/** What the list below shows right now: the run walks it in the same order. */
|
||||||
provider: string;
|
export interface View {
|
||||||
model: string;
|
kind: "lines" | "strings" | "glossary";
|
||||||
batch: number;
|
module: string;
|
||||||
outdated: boolean;
|
q: string;
|
||||||
strings: boolean;
|
|
||||||
}
|
|
||||||
const DEFAULT_PREFS: Prefs = { provider: "", model: "", batch: 10, outdated: false, strings: true };
|
|
||||||
|
|
||||||
function loadPrefs(): Prefs {
|
|
||||||
try {
|
|
||||||
return { ...DEFAULT_PREFS, ...JSON.parse(localStorage.getItem(PREFS_KEY) ?? "{}") };
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_PREFS;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function instructionsKey(gameId: number) {
|
type State = "untranslated" | "outdated";
|
||||||
return `pg.translator.instructions.${gameId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readText(key: string): string {
|
/** Machine translation of the list on screen through the translator running on this computer. */
|
||||||
try {
|
export function MachineTranslate({ session, versionId, lang, view, onFill, onStats, onClose }: {
|
||||||
return localStorage.getItem(key) ?? "";
|
session: Session; versionId: string; lang: string; view: View;
|
||||||
} catch {
|
/** a page of translations has just been saved: show them in the rows */
|
||||||
return "";
|
onFill: (kind: "lines" | "strings", results: { key: string; text: string }[]) => void;
|
||||||
}
|
onStats: (stats: Partial<LangStats>) => void;
|
||||||
}
|
onClose: () => void;
|
||||||
|
|
||||||
type Phase = { kind: "lines" | "strings"; state: "untranslated" | "outdated" };
|
|
||||||
|
|
||||||
/** Machine translation of one language through the translator running on this computer. */
|
|
||||||
export function MachineTranslate({ versionId, version, lang, ov, onClose, onChanged }: {
|
|
||||||
versionId: string; version: Version; lang: string; ov: Overview; onClose: () => void; onChanged: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const client = useMemo(() => new TranslatorClient(normalizeUrl(savedUrl())), []);
|
const { client, link, ready, prefs, setPref } = session;
|
||||||
const [link, setLink] = useState<Connection | "checking">("checking");
|
|
||||||
const [providers, setProviders] = useState<string[]>([]);
|
|
||||||
const [models, setModels] = useState<string[] | null>(null);
|
|
||||||
const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
|
|
||||||
const [instructions, setInstructions] = useState(() => readText(instructionsKey(version.game_id)));
|
|
||||||
const [glossary, setGlossary] = useState<GlossaryTerm[]>([]);
|
|
||||||
const [game, setGame] = useState<Game | null>(null);
|
|
||||||
const [todo, setTodo] = useState<number | null>(null);
|
const [todo, setTodo] = useState<number | null>(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [progress, setProgress] = useState<Progress | null>(null);
|
const [progress, setProgress] = useState<Progress | null>(null);
|
||||||
@@ -63,108 +35,55 @@ export function MachineTranslate({ versionId, version, lang, ov, onClose, onChan
|
|||||||
const [summary, setSummary] = useState("");
|
const [summary, setSummary] = useState("");
|
||||||
const abort = useRef<AbortController | null>(null);
|
const abort = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const setPref = <K extends keyof Prefs>(key: K, value: Prefs[K]) => {
|
|
||||||
setPrefs((p) => {
|
|
||||||
const next = { ...p, [key]: value };
|
|
||||||
try {
|
|
||||||
localStorage.setItem(PREFS_KEY, JSON.stringify(next));
|
|
||||||
} catch {
|
|
||||||
/* the choice just is not remembered */
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const connect = useCallback(async () => {
|
|
||||||
setLink("checking");
|
|
||||||
const result = await client.diagnose();
|
|
||||||
setLink(result);
|
|
||||||
if (result.state !== "ok") return;
|
|
||||||
try {
|
|
||||||
const list = Object.keys(await client.providers());
|
|
||||||
setProviders(list);
|
|
||||||
setPrefs((p) => (list.includes(p.provider) ? p : { ...p, provider: list[0] ?? "" }));
|
|
||||||
} catch {
|
|
||||||
setProviders([]);
|
|
||||||
}
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
useEffect(() => void connect(), [connect]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<Game>(`/api/games/${version.game_id}`).then(setGame).catch(() => {});
|
if (link === "idle") void session.connect();
|
||||||
api.get<{ glossary: GlossaryTerm[] }>(`/api/versions/${versionId}/i18n/${lang}/meta`)
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
.then((m) => setGlossary(m.glossary ?? [])).catch(() => {});
|
}, []);
|
||||||
}, [version.game_id, versionId, lang]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const listable = view.kind !== "glossary";
|
||||||
if (!prefs.provider || link === "checking" || link.state !== "ok") return;
|
const states = useMemo<State[]>(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]);
|
||||||
let stale = false;
|
|
||||||
setModels(null);
|
|
||||||
client.models(prefs.provider).then((m) => !stale && setModels(m)).catch(() => !stale && setModels([]));
|
|
||||||
return () => {
|
|
||||||
stale = true;
|
|
||||||
};
|
|
||||||
}, [client, link, prefs.provider]);
|
|
||||||
|
|
||||||
const phases = useMemo<Phase[]>(() => {
|
const entriesUrl = useCallback((state: State, offset: number, limit: number) =>
|
||||||
const states: Phase["state"][] = prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"];
|
|
||||||
const kinds: Phase["kind"][] = prefs.strings ? ["lines", "strings"] : ["lines"];
|
|
||||||
return kinds.flatMap((kind) => states.map((state) => ({ kind, state })));
|
|
||||||
}, [prefs.outdated, prefs.strings]);
|
|
||||||
|
|
||||||
const entriesUrl = useCallback((p: Phase, offset: number, limit: number) =>
|
|
||||||
`/api/versions/${versionId}/i18n/${lang}/entries?${new URLSearchParams({
|
`/api/versions/${versionId}/i18n/${lang}/entries?${new URLSearchParams({
|
||||||
kind: p.kind, state: p.state, offset: String(offset), limit: String(limit) })}`, [versionId, lang]);
|
kind: view.kind, state, module: view.module, q: view.q, offset: String(offset), limit: String(limit) })}`,
|
||||||
|
[versionId, lang, view.kind, view.module, view.q]);
|
||||||
|
|
||||||
// how much there is to translate with the current choices
|
// how much there is to translate with the current list and options
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (running) return;
|
if (running || !listable) return;
|
||||||
let stale = false;
|
let stale = false;
|
||||||
Promise.all(phases.map((p) => api.get<{ total: number }>(entriesUrl(p, 0, 1))))
|
Promise.all(states.map((s) => api.get<{ total: number }>(entriesUrl(s, 0, 1))))
|
||||||
.then((r) => !stale && setTodo(r.reduce((n, x) => n + x.total, 0))).catch(() => !stale && setTodo(null));
|
.then((r) => !stale && setTodo(r.reduce((n, x) => n + x.total, 0))).catch(() => !stale && setTodo(null));
|
||||||
return () => {
|
return () => {
|
||||||
stale = true;
|
stale = true;
|
||||||
};
|
};
|
||||||
}, [phases, entriesUrl, running, summary]);
|
}, [states, entriesUrl, running, listable, summary]);
|
||||||
|
|
||||||
useEffect(() => () => abort.current?.abort(), []);
|
useEffect(() => () => abort.current?.abort(), []);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
|
if (view.kind === "glossary") return;
|
||||||
|
const kind = view.kind;
|
||||||
setError("");
|
setError("");
|
||||||
setSummary("");
|
setSummary("");
|
||||||
|
setProgress(null);
|
||||||
const ctl = new AbortController();
|
const ctl = new AbortController();
|
||||||
abort.current = ctl;
|
abort.current = ctl;
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
const base = { total: 0, done: 0, failed: 0 };
|
const base = { total: 0, done: 0, failed: 0 };
|
||||||
let last: Progress = { ...base, lastError: "", model: null };
|
let last: Progress = { ...base, lastError: "", model: null };
|
||||||
try {
|
try {
|
||||||
try {
|
const projectId = await session.project();
|
||||||
localStorage.setItem(instructionsKey(version.game_id), instructions);
|
for (const state of states) {
|
||||||
} catch {
|
|
||||||
/* not remembered */
|
|
||||||
}
|
|
||||||
const title = game?.title ?? version.name;
|
|
||||||
const projectId = await client.saveProject({
|
|
||||||
name: `PGames: ${title} (${ov.sourceLang ?? "?"} → ${lang})`,
|
|
||||||
source_language: languageName(ov.sourceLang ?? "en", ov.knownLanguages),
|
|
||||||
target_language: languageName(lang, ov.knownLanguages),
|
|
||||||
context: [`Game: ${title}.`, game?.description, game?.tags?.length ? `Tags: ${game.tags.join(", ")}.` : ""]
|
|
||||||
.filter(Boolean).join(" ").slice(0, 4000),
|
|
||||||
instructions: instructions.trim(),
|
|
||||||
glossary: Object.fromEntries(glossary.filter((g) => g.src.trim() && g.t.trim()).map((g) => [g.src.trim(), g.t.trim()])),
|
|
||||||
provider: prefs.provider || "lmstudio",
|
|
||||||
model: prefs.model || null,
|
|
||||||
});
|
|
||||||
for (const phase of phases) {
|
|
||||||
const outcome = await runTranslation({
|
const outcome = await runTranslation({
|
||||||
client, projectId, batchSize: prefs.batch, pageSize: PAGE, signal: ctl.signal,
|
client, projectId, batchSize: prefs.batch, pageSize: PAGE, signal: ctl.signal,
|
||||||
fetchPage: (skip, limit) => api.get<{ total: number; items: (Source & { translation?: string })[] }>(
|
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(entriesUrl(state, skip, limit)),
|
||||||
entriesUrl(phase, skip, limit)),
|
|
||||||
save: async (results) => {
|
save: async (results) => {
|
||||||
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
|
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
|
||||||
await api.put(`/api/versions/${versionId}/i18n/${lang}`,
|
const stats = await api.put<Partial<LangStats>>(`/api/versions/${versionId}/i18n/${lang}`,
|
||||||
phase.kind === "strings" ? { strings: changes } : { lines: changes });
|
kind === "strings" ? { strings: changes } : { lines: changes });
|
||||||
|
onFill(kind, results);
|
||||||
|
onStats(stats);
|
||||||
},
|
},
|
||||||
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 };
|
||||||
@@ -181,15 +100,14 @@ export function MachineTranslate({ versionId, version, lang, ov, onClose, onChan
|
|||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
abort.current = null;
|
abort.current = null;
|
||||||
onChanged();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = () => abort.current?.abort();
|
const stop = () => abort.current?.abort();
|
||||||
|
|
||||||
const health = link !== "checking" && link.state === "ok" ? link.health : null;
|
const health = link !== "idle" && link !== "checking" && link.state === "ok" ? link.health : null;
|
||||||
const ready = health !== null;
|
|
||||||
const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0;
|
const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0;
|
||||||
|
const scope = view.kind === "strings" ? "имена, меню и интерфейс" : "реплики";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card mt-panel" aria-label="Машинный перевод">
|
<section className="card mt-panel" aria-label="Машинный перевод">
|
||||||
@@ -199,23 +117,25 @@ export function MachineTranslate({ versionId, version, lang, ov, onClose, onChan
|
|||||||
</div>
|
</div>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой
|
Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой
|
||||||
«Машинный». <Link to="/translator">Как установить и настроить</Link>
|
«Машинный». <Link to="/translator">Как установить и настроить</Link>. Отдельную строку можно перевести
|
||||||
|
кнопкой «Перевести» рядом с ней.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{link === "checking" && <p className="muted small">Ищу переводчик на {client.base}…</p>}
|
{(link === "idle" || link === "checking") && <p className="muted small">Ищу переводчик на {client.base}…</p>}
|
||||||
{link !== "checking" && link.state === "offline" && (
|
{typeof link === "object" && link.state === "offline" && (
|
||||||
<ErrorNote>Переводчик не отвечает на {client.base}. Запустите его (<Link to="/translator">инструкция</Link>)
|
<ErrorNote>Переводчик не отвечает на {client.base}. Запустите его (<Link to="/translator">инструкция</Link>)
|
||||||
и нажмите «Проверить снова».</ErrorNote>
|
и нажмите «Проверить снова».</ErrorNote>
|
||||||
)}
|
)}
|
||||||
{link !== "checking" && link.state === "blocked" && (
|
{typeof link === "object" && link.state === "blocked" && (
|
||||||
<ErrorNote>Переводчик запущен, но браузер не пускает этот сайт к нему. Добавьте <b>{location.origin}</b> в
|
<ErrorNote>Переводчик запущен, но браузер не пускает этот сайт к нему. Добавьте <b>{location.origin}</b> в
|
||||||
allowed_origins в config.toml и перезапустите переводчик (<Link to="/translator">подробнее</Link>).</ErrorNote>
|
allowed_origins в config.toml (в начале файла, выше первой секции) и перезапустите переводчик
|
||||||
|
(<Link to="/translator">подробнее</Link>).</ErrorNote>
|
||||||
)}
|
)}
|
||||||
{link !== "checking" && link.state === "old" && (
|
{typeof link === "object" && link.state === "old" && (
|
||||||
<ErrorNote>Версия переводчика слишком старая: обновите репозиторий local-translator и перезапустите его.</ErrorNote>
|
<ErrorNote>Версия переводчика слишком старая: обновите репозиторий local-translator и перезапустите его.</ErrorNote>
|
||||||
)}
|
)}
|
||||||
{link !== "checking" && link.state !== "ok" && (
|
{typeof link === "object" && link.state !== "ok" && (
|
||||||
<div className="actions"><button className="btn btn-sm" onClick={() => void connect()}><Icon name="retry" size={16} />Проверить снова</button></div>
|
<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>}
|
{health?.startup_errors[prefs.provider] && <ErrorNote>{health.startup_errors[prefs.provider]}</ErrorNote>}
|
||||||
|
|
||||||
@@ -225,17 +145,17 @@ export function MachineTranslate({ versionId, version, lang, ov, onClose, onChan
|
|||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Источник</span>
|
<span>Источник</span>
|
||||||
<select value={prefs.provider} disabled={running} onChange={(e) => { setPref("provider", e.target.value); setPref("model", ""); }}>
|
<select value={prefs.provider} disabled={running} onChange={(e) => { setPref("provider", e.target.value); setPref("model", ""); }}>
|
||||||
{providers.map((p) => <option key={p} value={p}>{p}</option>)}
|
{session.providers.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Модель</span>
|
<span>Модель</span>
|
||||||
<select value={prefs.model} disabled={running} onChange={(e) => setPref("model", e.target.value)}>
|
<select value={prefs.model} disabled={running} onChange={(e) => setPref("model", e.target.value)}>
|
||||||
<option value="">Как в настройках переводчика</option>
|
<option value="">Как в настройках переводчика</option>
|
||||||
{(models ?? []).map((m) => <option key={m} value={m}>{m}</option>)}
|
{(session.models ?? []).map((m) => <option key={m} value={m}>{m}</option>)}
|
||||||
{prefs.model && !(models ?? []).includes(prefs.model) && <option value={prefs.model}>{prefs.model}</option>}
|
{prefs.model && !(session.models ?? []).includes(prefs.model) && <option value={prefs.model}>{prefs.model}</option>}
|
||||||
</select>
|
</select>
|
||||||
{models !== null && !models.length && (
|
{session.models !== null && !session.models.length && (
|
||||||
<small className="hint">Список моделей пуст: запустите сервер в LM Studio и загрузите модель.</small>
|
<small className="hint">Список моделей пуст: запустите сервер в LM Studio и загрузите модель.</small>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
@@ -249,28 +169,35 @@ export function MachineTranslate({ versionId, version, lang, ov, onClose, onChan
|
|||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Пожелания к переводу</span>
|
<span>Пожелания к переводу</span>
|
||||||
<textarea rows={2} value={instructions} disabled={running} maxLength={5000}
|
<textarea rows={2} value={session.instructions} disabled={running} maxLength={5000}
|
||||||
placeholder="Например: обращение на «ты», сохраняй грубость оригинала. Запоминается для этой игры."
|
placeholder="Например: обращение на «ты», сохраняй грубость оригинала. Запоминается для этой игры."
|
||||||
onChange={(e) => setInstructions(e.target.value)} />
|
onChange={(e) => session.setInstructions(e.target.value)} />
|
||||||
<small className="hint">Имена и термины берутся из вкладки «Глоссарий» ({glossary.filter((g) => g.t.trim()).length}).</small>
|
<small className="hint">Имена и термины берутся из вкладки «Глоссарий».</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-options">
|
<div className="mt-options">
|
||||||
<label className="check"><input type="checkbox" checked={prefs.strings} disabled={running}
|
<label className="check" title="Описание и жанры помогают выбрать тон и термины"><input type="checkbox" checked={prefs.about} disabled={running}
|
||||||
onChange={(e) => setPref("strings", e.target.checked)} /> Имена, меню и интерфейс</label>
|
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={running}
|
||||||
onChange={(e) => setPref("outdated", e.target.checked)} /> Обновить устаревшие переводы</label>
|
onChange={(e) => setPref("outdated", e.target.checked)} /> Обновить устаревшие переводы</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="muted small">
|
||||||
|
{listable
|
||||||
|
? <>Переводится то, что открыто ниже: {scope}{view.module ? `, файл ${view.module.replace(/^script\//, "").replace(/\.json$/, "")}` : ""}
|
||||||
|
{view.q ? `, по поиску «${view.q}»` : ""}. Идёт сверху вниз, как в списке, переведённое появляется в строках сразу.</>
|
||||||
|
: "Откройте вкладку «Реплики» или «Имена и меню», чтобы переводить."}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{running ? (
|
{running ? (
|
||||||
<button className="btn" onClick={stop}><Icon name="x" size={18} />Остановить</button>
|
<button className="btn" onClick={stop}><Icon name="x" size={18} />Остановить</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-primary" onClick={() => void start()} disabled={!todo || !prefs.provider}>
|
<button className="btn btn-primary" onClick={() => void start()} disabled={!listable || !todo || !prefs.provider}>
|
||||||
<Icon name="play" size={18} />Перевести{todo ? ` · ${todo}` : ""}
|
<Icon name="play" size={18} />Перевести{todo ? ` · ${todo}` : ""}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{todo === 0 && !running && <span className="muted small">Всё уже переведено.</span>}
|
{listable && todo === 0 && !running && <span className="muted small">Здесь всё уже переведено.</span>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -279,8 +206,8 @@ export function MachineTranslate({ versionId, version, lang, ov, onClose, onChan
|
|||||||
<div className="mt-progress" aria-live="polite">
|
<div className="mt-progress" aria-live="polite">
|
||||||
<div className="progress"><div style={{ width: `${percent}%` }} /></div>
|
<div className="progress"><div style={{ width: `${percent}%` }} /></div>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
{progress.done} из {progress.total}{progress.failed ? ` · ошибок ${progress.failed}` : ""}
|
Переведено {progress.done} из {progress.total}{progress.failed ? ` · ошибок ${progress.failed}` : ""}
|
||||||
{progress.model ? ` · ${progress.model}` : ""}
|
{progress.model ? ` · модель: ${progress.model}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ export function TranslatorSetup() {
|
|||||||
</p>
|
</p>
|
||||||
<Code copy>{config}</Code>
|
<Code copy>{config}</Code>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Адрес берётся из вашей адресной строки, без пути в конце. После правки перезапустите переводчик.
|
Адрес берётся из вашей адресной строки, без пути в конце. Строка должна стоять в начале файла, рядом с
|
||||||
|
<code> port</code>, выше первой секции в квадратных скобках, иначе она попадёт в настройки этой секции и переводчик
|
||||||
|
не запустится. После правки перезапустите переводчик.
|
||||||
Для разработки можно добавить несколько адресов через запятую.
|
Для разработки можно добавить несколько адресов через запятую.
|
||||||
</p>
|
</p>
|
||||||
</Step>
|
</Step>
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ const sleep = (ms: number, signal: AbortSignal) =>
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
async function waitForJob(opts: RunOptions, first: Job): Promise<Job> {
|
/** Polls a job until it is over; aborting cancels it and returns what was finished by then. */
|
||||||
|
export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel">, first: Job,
|
||||||
|
signal: AbortSignal, pollMs = 700): Promise<Job> {
|
||||||
|
const opts = { client, signal, pollMs };
|
||||||
let job = first;
|
let job = first;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
// cancelling returns the job as it stands, with whatever was finished before the stop
|
// cancelling returns the job as it stands, with whatever was finished before the stop
|
||||||
@@ -81,7 +84,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
|||||||
|
|
||||||
let job = await opts.client.createJob(
|
let job = await opts.client.createJob(
|
||||||
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
|
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
|
||||||
job = await waitForJob(opts, job);
|
job = await waitForJob(opts.client, job, opts.signal, opts.pollMs);
|
||||||
progress.model = job.model ?? progress.model;
|
progress.model = job.model ?? progress.model;
|
||||||
|
|
||||||
const ready: { key: string; text: string }[] = [];
|
const ready: { key: string; text: string }[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// The connection to the local translator for one language of one version, shared by the
|
||||||
|
// machine translation panel and the per-line translate buttons.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { api, type Game, type Version } from "../api";
|
||||||
|
import type { GlossaryTerm, Overview } from "../pages/translation-types";
|
||||||
|
import { TranslatorClient, languageName, normalizeUrl, savedUrl, type Link } from "./client";
|
||||||
|
import { waitForJob } from "./run";
|
||||||
|
|
||||||
|
const PREFS_KEY = "pg.translator.prefs";
|
||||||
|
const USED_KEY = "pg.translator.used";
|
||||||
|
|
||||||
|
export interface Prefs {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
batch: number;
|
||||||
|
outdated: boolean;
|
||||||
|
/** give the model the game's description and genres as context */
|
||||||
|
about: boolean;
|
||||||
|
}
|
||||||
|
const DEFAULT_PREFS: Prefs = { provider: "", model: "", batch: 10, outdated: false, about: true };
|
||||||
|
|
||||||
|
function read(key: string): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key: string, value: string) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
/* the choice just is not remembered */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPrefs(): Prefs {
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_PREFS, ...JSON.parse(read(PREFS_KEY) || "{}") };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PREFS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Connection = Link | "idle" | "checking";
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
client: TranslatorClient;
|
||||||
|
link: Connection;
|
||||||
|
/** the translator answers and is new enough: translate buttons may be shown */
|
||||||
|
ready: boolean;
|
||||||
|
connect: () => Promise<void>;
|
||||||
|
providers: string[];
|
||||||
|
models: string[] | null;
|
||||||
|
prefs: Prefs;
|
||||||
|
setPref: <K extends keyof Prefs>(key: K, value: Prefs[K]) => void;
|
||||||
|
instructions: string;
|
||||||
|
setInstructions: (value: string) => void;
|
||||||
|
/** 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 */
|
||||||
|
translateOne: (text: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranslatorSession(versionId: string | undefined, version: Version | null, lang: string,
|
||||||
|
ov: Overview | null): Session {
|
||||||
|
const client = useMemo(() => new TranslatorClient(normalizeUrl(savedUrl())), []);
|
||||||
|
const [link, setLink] = useState<Connection>("idle");
|
||||||
|
const [providers, setProviders] = useState<string[]>([]);
|
||||||
|
const [models, setModels] = useState<string[] | null>(null);
|
||||||
|
const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
|
||||||
|
const [instructions, setInstr] = useState("");
|
||||||
|
const game = useRef<Game | null>(null);
|
||||||
|
const gameId = version?.game_id;
|
||||||
|
|
||||||
|
const setPref = useCallback(<K extends keyof Prefs>(key: K, value: Prefs[K]) => {
|
||||||
|
setPrefs((p) => {
|
||||||
|
const next = { ...p, [key]: value };
|
||||||
|
write(PREFS_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
setLink("checking");
|
||||||
|
const result = await client.diagnose();
|
||||||
|
setLink(result);
|
||||||
|
if (result.state !== "ok") return;
|
||||||
|
write(USED_KEY, "1");
|
||||||
|
try {
|
||||||
|
const list = Object.keys(await client.providers());
|
||||||
|
setProviders(list);
|
||||||
|
setPrefs((p) => (list.includes(p.provider) ? p : { ...p, provider: list[0] ?? "" }));
|
||||||
|
} catch {
|
||||||
|
setProviders([]);
|
||||||
|
}
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
// Browsers ask before a page talks to the local network, so only look for the translator
|
||||||
|
// unprompted when it has worked here before; otherwise the panel's button does it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (read(USED_KEY)) void connect();
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameId !== undefined) setInstr(read(`pg.translator.instructions.${gameId}`));
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
const setInstructions = useCallback((value: string) => {
|
||||||
|
setInstr(value);
|
||||||
|
if (gameId !== undefined) write(`pg.translator.instructions.${gameId}`, value);
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
const ready = link !== "idle" && link !== "checking" && link.state === "ok";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || !prefs.provider) return;
|
||||||
|
let stale = false;
|
||||||
|
setModels(null);
|
||||||
|
client.models(prefs.provider).then((m) => !stale && setModels(m)).catch(() => !stale && setModels([]));
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
}, [client, ready, prefs.provider]);
|
||||||
|
|
||||||
|
const project = useCallback(async () => {
|
||||||
|
if (!version || !ov || !versionId) throw new Error("Версия ещё не загружена");
|
||||||
|
game.current ??= await api.get<Game>(`/api/games/${version.game_id}`).catch(() => null);
|
||||||
|
// fresh on every use: the glossary may have been edited since the last line
|
||||||
|
const meta = await api.get<{ glossary: GlossaryTerm[] }>(`/api/versions/${versionId}/i18n/${lang}/meta`)
|
||||||
|
.catch(() => ({ glossary: [] as GlossaryTerm[] }));
|
||||||
|
const g = game.current;
|
||||||
|
const title = g?.title ?? version.name;
|
||||||
|
return client.saveProject({
|
||||||
|
name: `PGames: ${title} (${ov.sourceLang ?? "?"} → ${lang})`,
|
||||||
|
source_language: languageName(ov.sourceLang ?? "en", ov.knownLanguages),
|
||||||
|
target_language: languageName(lang, ov.knownLanguages),
|
||||||
|
context: [`Game: ${title}.`, prefs.about && g?.description,
|
||||||
|
prefs.about && g?.tags?.length ? `Genres and tags: ${g.tags.join(", ")}.` : ""]
|
||||||
|
.filter(Boolean).join(" ").slice(0, 4000),
|
||||||
|
instructions: instructions.trim(),
|
||||||
|
glossary: Object.fromEntries((meta.glossary ?? []).filter((t) => t.src.trim() && t.t.trim())
|
||||||
|
.map((t) => [t.src.trim(), t.t.trim()])),
|
||||||
|
provider: prefs.provider || "lmstudio",
|
||||||
|
model: prefs.model || null,
|
||||||
|
});
|
||||||
|
}, [client, version, ov, versionId, lang, instructions, prefs.provider, prefs.model, prefs.about]);
|
||||||
|
|
||||||
|
const translateOne = useCallback(async (text: string) => {
|
||||||
|
const id = await project();
|
||||||
|
const done = await waitForJob(client, await client.createJob(id, [{ id: "0", text }], 1), new AbortController().signal);
|
||||||
|
const item = done.items[0];
|
||||||
|
if (item?.status === "completed" && item.translation?.trim()) return item.translation;
|
||||||
|
throw new Error(item?.error || done.error || "Модель не вернула перевод");
|
||||||
|
}, [client, project]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client, link, ready, connect, providers, models, prefs, setPref, instructions, setInstructions, project, translateOne,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user