feat(translate): highlighted Install affordance for uninstalled engines + dismissable/auto-clearing error banner (#847)
Two related Dub-tab translation-flow fixes, one PR. TASK 1 — proactive, highlighted Install affordance in the translate engine selector (replaces "find out only via a translate-time 400"): - FROM-SOURCE lane (activeEngineUnavailable && !enginesSandboxed): the muted install chip is promoted to a HIGHLIGHTED brand-accent Install button, still wired to handleInstallEngine(translateProvider) with the installing/disabled state. Selecting any uninstalled engine surfaces it immediately. - FROZEN lane (enginesSandboxed): pip install is impossible in the read-only, signed packaged env, so the disabled "needs dev install" span becomes an equally highlighted button opening a popover with (1) the exact install command + copy-to-clipboard, (2) one-click "Switch to Argos (bundled, offline)" — the guaranteed importable escape hatch, and (3) a Docs link via the existing Tauri shell.open path. Gated on the existing `sandboxed` flag, not platform. - Single-source install command: new translation_engines.install_command() is the one source of truth; list_engines() stamps `install_command` per engine and BOTH the argos + deep_translator translate-time 400 messages build their command from it, so the proactive button and the 400 can't drift. engines.ts gains `install_command: string | null`. TASK 2 — the translation error banner now dismisses and clears (class fix): - Root cause: handleTranslateAll never cleared dubError, so a stale 400 survived even a successful retry. It now clears at the start of every attempt. - Corrective-action clears (whole class): changing the engine and installing the package both clear dubError (wrapped setTranslateProvider + handleInstallEngine in DubTab). - DubFooter's banner gains a × dismiss and a guarded auto-timeout (skipped while generating so live per-segment errors persist). i18n: 8 new dub.* keys translated across all 21 locales. Docs: new docs/dubbing/translation-engines.md (from-source vs packaged build) linked from the popover Docs button + a troubleshooting cross-reference. Tests: FE regression for both lanes + never-installs-when-sandboxed + banner dismiss/auto-clear; BE regression that list_engines() install_command is embedded verbatim in the dub_translate 400s. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Opus 4.8
parent
66ad03948b
commit
522bbddccf
@@ -36,6 +36,26 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
- **Factory reset in Storage.** A confirm-dialog-guarded action that clears the
|
||||
locally-saved UI preferences and reloads — without touching your voices,
|
||||
projects, or generated audio on disk.
|
||||
- **Proactive, highlighted "Install" affordance for translation engines.** When
|
||||
you pick a Dub translation engine whose optional package isn't installed yet
|
||||
(e.g. Google / DeepL via `deep_translator`), the Engine selector now surfaces a
|
||||
bright accent **Install** button *before* you hit Translate — no more
|
||||
discovering the missing package only via a translate-time 400. On a from-source
|
||||
install it one-click installs into the backend's own interpreter; on a
|
||||
read-only **packaged build** it opens a popover with the exact `uv pip install …`
|
||||
command (copy-to-clipboard), a one-click **Switch to Argos (bundled, offline)**
|
||||
escape hatch, and a docs link. The install command is single-sourced in the
|
||||
backend registry, so the button and the 400 error can never disagree. New guide:
|
||||
`docs/dubbing/translation-engines.md`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The "TRANSLATION FAILED" banner now dismisses and clears itself.** The Dub
|
||||
translation-error banner used to be sticky — it survived a successful re-try and
|
||||
never went away. It now has a close (×), auto-clears on the next corrective
|
||||
action (re-translating, changing the engine, or installing the package), and
|
||||
self-clears after a short timeout — fixing the whole class of translate/pipeline
|
||||
banners that outlived the state that caused them.
|
||||
|
||||
## [0.3.8] — 2026-06-29
|
||||
|
||||
|
||||
@@ -413,11 +413,16 @@ async def dub_translate(req: TranslateRequest):
|
||||
try:
|
||||
import argostranslate # noqa: F401
|
||||
except ImportError:
|
||||
# Single-source the install command from the engine registry so
|
||||
# this 400 and the proactive Install button in the Engine
|
||||
# selector can never drift (see translation_engines.install_command).
|
||||
from services.translation_engines import install_command
|
||||
cmd = install_command("argos") or "uv pip install argostranslate"
|
||||
friendly = (
|
||||
f"The '{provider}' translation engine needs the optional "
|
||||
f"`argostranslate` Python package, which isn't installed in "
|
||||
f"this backend. Install it with `uv pip install argostranslate` "
|
||||
f"(or `pip install argostranslate`) and restart the server, or "
|
||||
f"this backend. Install it with `{cmd}` "
|
||||
f"and restart the server, or "
|
||||
f"switch the Engine dropdown to another provider."
|
||||
)
|
||||
return JSONResponse(status_code=400, content={"error": friendly})
|
||||
@@ -470,11 +475,16 @@ async def dub_translate(req: TranslateRequest):
|
||||
try:
|
||||
import deep_translator # noqa: F401
|
||||
except ImportError:
|
||||
# Same single-source install command as the Engine selector's Install
|
||||
# button (translation_engines.install_command) — google/deepl/
|
||||
# microsoft/mymemory all share the deep_translator package.
|
||||
from services.translation_engines import install_command
|
||||
cmd = install_command(provider) or "uv pip install deep_translator"
|
||||
friendly = (
|
||||
f"The '{provider}' translation engine needs the optional "
|
||||
f"`deep_translator` Python package, which isn't installed in "
|
||||
f"this backend. Install it with `uv pip install deep_translator` "
|
||||
f"(or `pip install deep_translator`) and restart the server, or "
|
||||
f"this backend. Install it with `{cmd}` "
|
||||
f"and restart the server, or "
|
||||
f"switch the Engine dropdown to Argos (local, bundled), NLLB "
|
||||
f"(local, heavier), or OpenAI (LLM)."
|
||||
)
|
||||
|
||||
@@ -120,6 +120,23 @@ def _probe(entry: dict) -> tuple[bool, str]:
|
||||
return False, f"import {mod!r} failed: {e}"
|
||||
|
||||
|
||||
def install_command(engine: "str | dict | None") -> str | None:
|
||||
"""The exact shell command that makes this engine importable, or None.
|
||||
|
||||
Single source of truth for the install string. BOTH the proactive Install
|
||||
affordance in the Engine selector (via list_engines' ``install_command``
|
||||
field) AND the translate-time 400 error (dub_translate.py) read from here,
|
||||
so the command a user is told to run can never drift between the two
|
||||
surfaces. Returns None when the engine needs no separate install — either
|
||||
it's unknown or its dependency is a core dep already pinned in
|
||||
``pyproject.toml`` (e.g. NLLB → transformers), in which case a
|
||||
``uv pip install`` line would be misleading.
|
||||
"""
|
||||
entry = engine if isinstance(engine, dict) else REGISTRY.get(engine) if engine else None
|
||||
pkg = entry.get("pip_package") if entry else None
|
||||
return f"uv pip install {pkg}" if pkg else None
|
||||
|
||||
|
||||
def list_engines() -> list[dict]:
|
||||
"""Return a UI-ready list with per-engine availability stamped in."""
|
||||
out = []
|
||||
@@ -129,6 +146,7 @@ def list_engines() -> list[dict]:
|
||||
**e,
|
||||
"installed": installed,
|
||||
"availability_reason": reason,
|
||||
"install_command": install_command(e),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Translation engines (Dub tab)
|
||||
|
||||
OmniVoice dubs in two steps: **transcribe → translate → speak**. The *translate*
|
||||
step is pluggable — pick the engine in the Dub tab's **Engine** dropdown. Two
|
||||
engines are **built in** and always available offline; the rest need a small
|
||||
optional Python package.
|
||||
|
||||
| Engine | Category | Needs a package? | Key needed? |
|
||||
|--------|----------|------------------|-------------|
|
||||
| **Argos** (Local, Fast) | offline | `argostranslate` (bundled) | no |
|
||||
| **NLLB-200** (Local, Heavy) | offline | none (uses core `transformers`) | no |
|
||||
| Google Translate (Free) | online | `deep_translator` | no |
|
||||
| DeepL | online | `deep_translator` | yes (`DEEPL_API_KEY`) |
|
||||
| Microsoft Translator | online | `deep_translator` | yes (`MICROSOFT_API_KEY`) |
|
||||
| MyMemory | online | `deep_translator` | no |
|
||||
| LLM (OpenAI-compatible) | llm | `openai` | usually yes |
|
||||
|
||||
If you pick an engine whose package isn't importable yet, the Engine label shows
|
||||
a **highlighted Install affordance**, and — if you try to translate anyway — the
|
||||
backend returns a single, actionable error telling you exactly what to install
|
||||
(the install command is single-sourced, so the button and the error never
|
||||
disagree).
|
||||
|
||||
## Installing optional translation engines (from-source vs packaged build)
|
||||
|
||||
How you add an engine depends on **how you installed OmniVoice**.
|
||||
|
||||
### From-source / dev install (one-click)
|
||||
|
||||
If you cloned the repo and run OmniVoice from source (`uv sync` + the dev
|
||||
launcher) or via Docker, the app can install engines for you:
|
||||
|
||||
1. In the Dub tab, open the translation settings and pick the engine you want
|
||||
(e.g. **Google Translate**) from the **Engine** dropdown.
|
||||
2. A highlighted **Install** button appears next to the *Engine* label. Click it.
|
||||
3. OmniVoice runs the install into the **same** Python environment the backend
|
||||
is using (`uv pip install <package> --python <backend-interpreter>`), then
|
||||
re-probes. When it reports *"restart the backend to load it"*, restart so the
|
||||
freshly-installed module is importable.
|
||||
|
||||
You can also install by hand into the backend venv:
|
||||
|
||||
```
|
||||
uv pip install deep_translator # Google / DeepL / Microsoft / MyMemory
|
||||
uv pip install argostranslate # Argos (already bundled; rarely needed)
|
||||
uv pip install openai # LLM (OpenAI-compatible) provider
|
||||
```
|
||||
|
||||
Then restart the backend.
|
||||
|
||||
### Packaged / installer build (read-only — use the popover)
|
||||
|
||||
The signed desktop installers (`.dmg`, `.msi`, AppImage, `.deb`) ship a
|
||||
**read-only, code-signed Python environment**. Installing extra packages into it
|
||||
would break the signature, so **in-app install is intentionally disabled** on
|
||||
these builds. Selecting an uninstalled engine there shows a highlighted button
|
||||
that opens a small popover with everything you need:
|
||||
|
||||
- **The exact command** to run (with a copy-to-clipboard button) if you *do*
|
||||
have a from-source checkout somewhere and want the online engines there.
|
||||
- **Switch to Argos (bundled, offline)** — one click. Argos and NLLB are always
|
||||
importable in every build, so this is the guaranteed escape hatch: you can
|
||||
keep dubbing immediately, fully offline, no install required.
|
||||
- A link back to this page.
|
||||
|
||||
**Recommendation for packaged builds:** just use **Argos** (fast, offline) or
|
||||
**NLLB-200** (heavier, higher quality, offline). They need nothing installed and
|
||||
never leave your machine. Reach for the online engines only from a from-source
|
||||
install where you can add their package.
|
||||
|
||||
## API keys (online engines)
|
||||
|
||||
Some online engines need a key, set as an environment variable before launching
|
||||
the backend (or in **Settings → Credentials**):
|
||||
|
||||
- **DeepL:** `DEEPL_API_KEY` (optionally `DEEPL_BASE_URL` for a self-hosted /
|
||||
pro endpoint).
|
||||
- **Microsoft Translator:** `MICROSOFT_API_KEY` (optionally `MICROSOFT_BASE_URL`).
|
||||
- **LLM provider:** `TRANSLATE_BASE_URL` + `TRANSLATE_API_KEY` + `TRANSLATE_MODEL`
|
||||
(Ollama / LM Studio run locally and need no real key).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"The 'google' translation engine needs the optional deep_translator Python
|
||||
package…"** — the package isn't installed. On a from-source install, click the
|
||||
Install button (or run the command above) and restart. On a packaged build,
|
||||
switch to Argos/NLLB via the popover.
|
||||
- **Install button does nothing / says "disabled in packaged builds"** — you're
|
||||
on a signed installer build (expected). Use Argos/NLLB, or add the package in a
|
||||
from-source checkout.
|
||||
- **Installed it but still "needs install"** — restart the backend so Python
|
||||
picks up the newly-installed module.
|
||||
@@ -345,6 +345,27 @@ after a timeout with this exact guidance. Tune the bound with
|
||||
`OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S` (seconds; default 300) — **raise** it for
|
||||
very long single files, **lower** it to fail faster on a small machine.
|
||||
|
||||
## Dub: "translation engine needs the optional … package"
|
||||
|
||||
**Symptom:** in the Dub tab, translating fails with e.g. *"The 'google'
|
||||
translation engine needs the optional `deep_translator` Python package, which
|
||||
isn't installed in this backend."*
|
||||
|
||||
**Cause:** the online translation engines (Google / DeepL / Microsoft / MyMemory
|
||||
via `deep_translator`, and the LLM provider via `openai`) are **optional** and
|
||||
not bundled. Only **Argos** and **NLLB** work out of the box.
|
||||
|
||||
**Fix:**
|
||||
- **From-source / Docker install:** click the highlighted **Install** button next
|
||||
to the *Engine* label in the Dub tab (or run `uv pip install deep_translator`
|
||||
in the backend venv) and restart the backend.
|
||||
- **Packaged installer build:** in-app install is disabled (read-only signed
|
||||
environment). Click the highlighted button to open the popover and **Switch to
|
||||
Argos (bundled, offline)** — or copy the command to run it in a from-source
|
||||
checkout.
|
||||
|
||||
Full guide: [dubbing/translation-engines.md](../dubbing/translation-engines.md#installing-optional-translation-engines-from-source-vs-packaged-build).
|
||||
|
||||
## First-run setup fails on a restricted network (GitHub/PyPI blocked)
|
||||
|
||||
On networks that block or can't resolve **GitHub**, the first-run bootstrap may
|
||||
|
||||
@@ -17,6 +17,9 @@ interface TranslationEngine {
|
||||
notes?: string;
|
||||
installed: boolean;
|
||||
availability_reason: string;
|
||||
/** `uv pip install <pkg>` (single-sourced by the backend registry), or
|
||||
* null when the engine needs no separate install (builtin/core dep). */
|
||||
install_command: string | null;
|
||||
}
|
||||
export interface TranslationEnginesResponse {
|
||||
engines: TranslationEngine[];
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Check, AlertCircle } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { Check, AlertCircle, X } from 'lucide-react';
|
||||
import { Badge } from '../../ui';
|
||||
import DubFailureNotice from './DubFailureNotice';
|
||||
|
||||
// How long a translate/pipeline error banner lingers before it self-clears.
|
||||
// Long enough to read a short message; the × and corrective-action clears are
|
||||
// the primary escape hatches — this is the belt-and-suspenders timeout.
|
||||
const ERROR_AUTOCLEAR_MS = 12000;
|
||||
|
||||
// Export-track toggle chips: flat pill outline, tinted by on/off/success state.
|
||||
const TRACK_LABEL =
|
||||
'inline-flex items-center gap-[4px] px-[8px] py-[2px] border border-transparent rounded-[var(--chrome-radius-pill)] cursor-pointer transition-colors';
|
||||
@@ -18,11 +24,24 @@ export default function DubFooter({
|
||||
incrementalPlan,
|
||||
dubError,
|
||||
dubFailure,
|
||||
onDismissError,
|
||||
exportTracks,
|
||||
setExportTracks,
|
||||
dubSegments,
|
||||
translateQuality,
|
||||
}) {
|
||||
// Auto-clear the error banner after a grace period so it can't get stuck
|
||||
// forever (issue: "TRANSLATION FAILED banner never goes away"). Skipped
|
||||
// while generating/stopping, where the banner accumulates live per-segment
|
||||
// errors the user needs to keep reading until the run ends.
|
||||
const canAutoClear =
|
||||
!!dubError && !!onDismissError && dubStep !== 'generating' && dubStep !== 'stopping';
|
||||
useEffect(() => {
|
||||
if (!canAutoClear) return undefined;
|
||||
const id = setTimeout(() => onDismissError(), ERROR_AUTOCLEAR_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [canAutoClear, dubError, onDismissError]);
|
||||
|
||||
return (
|
||||
<div className="px-[var(--space-3)] py-[4px] shrink-0 bg-[var(--chrome-bg)] border border-[var(--chrome-border)]">
|
||||
{dubStep === 'done' && (
|
||||
@@ -46,9 +65,22 @@ export default function DubFooter({
|
||||
)}
|
||||
{dubError && (
|
||||
<div className="mb-[var(--space-2)]">
|
||||
<Badge tone="danger">
|
||||
<AlertCircle size={11} /> {dubError}
|
||||
</Badge>
|
||||
<span className="inline-flex items-center gap-[4px]">
|
||||
<Badge tone="danger">
|
||||
<AlertCircle size={11} /> {dubError}
|
||||
</Badge>
|
||||
{onDismissError && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-[18px] h-[18px] rounded-[4px] text-[var(--chrome-fg-muted,#a89984)] hover:text-[var(--chrome-fg,#ebdbb2)] hover:bg-[rgba(255,255,255,0.08)] bg-transparent border-none cursor-pointer shrink-0"
|
||||
onClick={onDismissError}
|
||||
title={t('dub.dismiss_error')}
|
||||
aria-label={t('dub.dismiss_error')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<DubFailureNotice failure={dubFailure} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Loader,
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
UserSquare2,
|
||||
Languages,
|
||||
Wand2,
|
||||
Download,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-react';
|
||||
import { Button, Segmented, Progress } from '../../ui';
|
||||
import WaveformTimeline from '../WaveformTimeline';
|
||||
@@ -16,6 +21,9 @@ import { LANG_CODES } from '../../utils/languages';
|
||||
import ALL_LANGUAGES from '../../languages.json';
|
||||
import { POPULAR_LANGS, PRESETS } from '../../utils/constants';
|
||||
import { dialectOptionsFor, dialectLabel, dialectMatchesLang } from '../../api/dialects';
|
||||
import { copyText } from '../../utils/copyText';
|
||||
import { openExternal } from '../../api/external';
|
||||
import { TRANSLATION_ENGINES_DOCS } from '../../utils/errorDocsMap';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// ── Translation-settings bar utility class clusters ──────────────────────
|
||||
@@ -32,6 +40,11 @@ const FIELD_LABEL =
|
||||
const FIELD_INPUT = 'input-base !w-full !text-[0.65rem] !px-[5px] !py-[3px]';
|
||||
const ENGINE_CHIP =
|
||||
'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[rgba(211,134,155,0.14)] border border-[rgba(211,134,155,0.35)] text-[#d3869b] rounded-[999px] whitespace-nowrap transition-colors';
|
||||
// Highlighted accent Install affordance — brand accent (#d3869b) filled pill,
|
||||
// deliberately louder than ENGINE_CHIP so an uninstalled selected engine is an
|
||||
// obvious call to action rather than a muted footnote.
|
||||
const ENGINE_INSTALL_BTN =
|
||||
'inline-flex items-center gap-[3px] ml-[6px] px-[7px] py-[1px] text-[0.55rem] font-semibold leading-[1.5] bg-[#d3869b] hover:bg-[#e0a0b3] text-[#1d2021] border border-[#d3869b] rounded-[999px] whitespace-nowrap cursor-pointer transition-colors shadow-[0_0_0_2px_rgba(211,134,155,0.25)] disabled:opacity-60 disabled:cursor-default';
|
||||
|
||||
export default function DubLeftColumn({
|
||||
hasDubbedTrack,
|
||||
@@ -90,6 +103,41 @@ export default function DubLeftColumn({
|
||||
setMultiLangs,
|
||||
editSegments,
|
||||
}) {
|
||||
// Frozen-build (packaged/signed, read-only site-packages) escape-hatch
|
||||
// popover: pip install is impossible, so we surface the copyable command +
|
||||
// a one-click switch to the always-bundled Argos engine + a docs deeplink.
|
||||
const [installPopoverOpen, setInstallPopoverOpen] = useState(false);
|
||||
const installPopoverRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!installPopoverOpen) return undefined;
|
||||
const onDown = (e) => {
|
||||
if (installPopoverRef.current && !installPopoverRef.current.contains(e.target)) {
|
||||
setInstallPopoverOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') setInstallPopoverOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [installPopoverOpen]);
|
||||
// Command shown/copied in the frozen popover — single-sourced from the
|
||||
// backend registry (activeEngineEntry.install_command), with a defensive
|
||||
// fallback so the popover is never empty for a known-uninstalled engine.
|
||||
const installCmd =
|
||||
activeEngineEntry?.install_command ||
|
||||
(activeEngineEntry?.pip_package ? `uv pip install ${activeEngineEntry.pip_package}` : '');
|
||||
const copyInstallCmd = async () => {
|
||||
if (!installCmd) return;
|
||||
const ok = await copyText(installCmd);
|
||||
if (ok) toast.success(t('dub.install_cmd_copied'));
|
||||
else toast.error(t('dub.copy_failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-panel dub-panel-col">
|
||||
{hasDubbedTrack && (
|
||||
@@ -390,27 +438,98 @@ export default function DubLeftColumn({
|
||||
</div>
|
||||
)}
|
||||
<div className={`${FIELD} flex-[1.4_1_130px] min-w-[90px] ${FIELD_RESP}`}>
|
||||
<div className={FIELD_LABEL}>
|
||||
<div className={`${FIELD_LABEL} !overflow-visible flex items-center`}>
|
||||
{t('dub.engine_label')}
|
||||
{/* FROM-SOURCE lane: pip install works (uv pip install runs
|
||||
in-process). Promote the muted chip to a highlighted accent
|
||||
Install button so an uninstalled selected engine is an
|
||||
obvious call to action. Keys off translateProvider, so
|
||||
picking any uninstalled engine surfaces it immediately. */}
|
||||
{activeEngineUnavailable && !enginesSandboxed && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${ENGINE_CHIP} cursor-pointer hover:bg-[rgba(211,134,155,0.22)] disabled:opacity-55 disabled:cursor-default disabled:italic`}
|
||||
className={ENGINE_INSTALL_BTN}
|
||||
onClick={() => handleInstallEngine(translateProvider)}
|
||||
disabled={engineInstalling === translateProvider}
|
||||
title={t('dub.install_engine')}
|
||||
>
|
||||
{engineInstalling === translateProvider
|
||||
? t('dub.installing_engine')
|
||||
: `+ install ${activeEngineEntry?.pip_package || ''}`}
|
||||
{engineInstalling === translateProvider ? (
|
||||
<>
|
||||
<Loader className="spinner" size={9} /> {t('dub.installing_engine')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={9} />{' '}
|
||||
{t('dub.install_engine_pkg', {
|
||||
pkg: activeEngineEntry?.pip_package || '',
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* FROZEN lane: packaged build, site-packages is read-only +
|
||||
signed, so pip install is impossible. Offer a highlighted
|
||||
button that opens a popover with the copyable command, a
|
||||
one-click switch to bundled Argos, and a docs deeplink. */}
|
||||
{activeEngineUnavailable && enginesSandboxed && (
|
||||
<span
|
||||
className={`${ENGINE_CHIP} opacity-55 cursor-default italic`}
|
||||
title={t('dub.install_disabled_title')}
|
||||
>
|
||||
{t('dub.needs_dev_install')}
|
||||
<span className="relative inline-flex" ref={installPopoverRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={ENGINE_INSTALL_BTN}
|
||||
onClick={() => setInstallPopoverOpen((o) => !o)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={installPopoverOpen}
|
||||
title={t('dub.install_disabled_title')}
|
||||
>
|
||||
<Download size={9} /> {t('dub.needs_install_short')}
|
||||
</button>
|
||||
{installPopoverOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('dub.install_popover_title')}
|
||||
className="absolute z-20 top-[calc(100%+6px)] left-0 w-[290px] max-w-[80vw] p-[10px] flex flex-col gap-[8px] bg-[var(--chrome-bg,#282828)] border border-[var(--chrome-border-strong,#504945)] rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] normal-case text-left"
|
||||
>
|
||||
<div className="text-[0.68rem] font-semibold text-[var(--chrome-fg,#ebdbb2)] normal-case tracking-normal">
|
||||
{t('dub.install_popover_title')}
|
||||
</div>
|
||||
<p className="text-[0.62rem] leading-[1.4] text-[var(--chrome-fg-muted,#a89984)] m-0">
|
||||
{t('dub.install_popover_frozen_body')}
|
||||
</p>
|
||||
{installCmd && (
|
||||
<div className="flex items-stretch gap-[4px]">
|
||||
<code className="flex-1 min-w-0 px-[6px] py-[4px] text-[0.6rem] leading-[1.4] font-[family-name:var(--chrome-font-mono,monospace)] text-[var(--chrome-fg,#ebdbb2)] bg-[rgba(0,0,0,0.35)] border border-[var(--chrome-border,#3c3836)] rounded-[5px] overflow-x-auto whitespace-nowrap">
|
||||
{installCmd}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 inline-flex items-center justify-center px-[6px] rounded-[5px] border border-[var(--chrome-border,#3c3836)] text-[var(--chrome-fg-muted,#a89984)] hover:text-[var(--chrome-fg,#ebdbb2)] hover:border-[var(--chrome-border-strong,#504945)] cursor-pointer bg-transparent"
|
||||
onClick={copyInstallCmd}
|
||||
title={t('dub.copy_command')}
|
||||
aria-label={t('dub.copy_command')}
|
||||
>
|
||||
<Copy size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-[5px] px-[8px] py-[5px] text-[0.64rem] font-semibold bg-[#d3869b] hover:bg-[#e0a0b3] text-[#1d2021] border-none rounded-[6px] cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
setTranslateProvider('argos');
|
||||
setInstallPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<ArrowRightLeft size={11} /> {t('dub.switch_to_argos')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-[5px] self-start text-[0.6rem] text-[var(--chrome-fg-muted,#a89984)] hover:text-[var(--chrome-fg,#ebdbb2)] bg-transparent border-none cursor-pointer p-0"
|
||||
onClick={() => openExternal(TRANSLATION_ENGINES_DOCS)}
|
||||
>
|
||||
<ExternalLink size={10} /> {t('dub.open_docs')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -690,6 +690,11 @@ export default function useDubWorkflow({
|
||||
const handleTranslateAll = useCallback(async () => {
|
||||
if (!dubSegments.length || !dubLangCode) return;
|
||||
setIsTranslating(true);
|
||||
// Root cause of the "sticky TRANSLATION FAILED banner": a new translate
|
||||
// attempt never cleared the previous failure, so a stale 400 survived even
|
||||
// a successful retry. Clear it up front — the whole class of translate/
|
||||
// pipeline error banners should reset on the next relevant action.
|
||||
setDubError('');
|
||||
try {
|
||||
const data = await dubTranslate({
|
||||
segments: dubSegments.map((s) => ({
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "تم إلغاء التحقق"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "تثبيت {{pkg}}",
|
||||
"needs_install_short": "يتطلب التثبيت",
|
||||
"install_popover_title": "تثبيت هذا المحرك",
|
||||
"install_popover_frozen_body": "لا يمكن لهذه النسخة المحزومة تثبيت المحركات — فبيئة Python الخاصة بها للقراءة فقط وموقّعة. شغّل الأمر أدناه في تثبيت من المصدر، أو بدّل إلى محرك Argos المضمّن.",
|
||||
"switch_to_argos": "التبديل إلى Argos (مضمّن، دون اتصال)",
|
||||
"copy_command": "نسخ الأمر",
|
||||
"install_cmd_copied": "تم نسخ الأمر",
|
||||
"dismiss_error": "إغلاق",
|
||||
"transcribe": "نسخ",
|
||||
"translate": "ترجمة",
|
||||
"generate": "إنشاء",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Verifizierung widerrufen"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "{{pkg}} installieren",
|
||||
"needs_install_short": "Installation nötig",
|
||||
"install_popover_title": "Diese Engine installieren",
|
||||
"install_popover_frozen_body": "Diese paketierte Version kann keine Engines installieren – ihre Python-Umgebung ist schreibgeschützt und signiert. Führe den Befehl unten in einer Quellcode-Installation aus oder wechsle zur mitgelieferten Argos-Engine.",
|
||||
"switch_to_argos": "Zu Argos wechseln (integriert, offline)",
|
||||
"copy_command": "Befehl kopieren",
|
||||
"install_cmd_copied": "Befehl kopiert",
|
||||
"dismiss_error": "Schließen",
|
||||
"transcribe": "Transkribieren",
|
||||
"translate": "Übersetzen",
|
||||
"generate": "Generieren",
|
||||
|
||||
@@ -638,6 +638,14 @@
|
||||
"dubbed_segments_other": "{{count}} dubbed segments"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Install {{pkg}}",
|
||||
"needs_install_short": "Needs install",
|
||||
"install_popover_title": "Install this engine",
|
||||
"install_popover_frozen_body": "This packaged build can't install engines — its Python environment is read-only and signed. Run the command below in a from-source install, or switch to the bundled Argos engine.",
|
||||
"switch_to_argos": "Switch to Argos (bundled, offline)",
|
||||
"copy_command": "Copy command",
|
||||
"install_cmd_copied": "Command copied",
|
||||
"dismiss_error": "Dismiss",
|
||||
"transcribe": "Transcribe",
|
||||
"translate": "Translate",
|
||||
"generate": "Generate",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Verificación revocada"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Instalar {{pkg}}",
|
||||
"needs_install_short": "Requiere instalación",
|
||||
"install_popover_title": "Instalar este motor",
|
||||
"install_popover_frozen_body": "Esta versión empaquetada no puede instalar motores: su entorno de Python es de solo lectura y está firmado. Ejecuta el comando de abajo en una instalación desde el código fuente, o cambia al motor Argos incluido.",
|
||||
"switch_to_argos": "Cambiar a Argos (incluido, sin conexión)",
|
||||
"copy_command": "Copiar comando",
|
||||
"install_cmd_copied": "Comando copiado",
|
||||
"dismiss_error": "Descartar",
|
||||
"transcribe": "Transcribir",
|
||||
"translate": "Traducir",
|
||||
"generate": "generar",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Vérification révoquée"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Installer {{pkg}}",
|
||||
"needs_install_short": "Installation requise",
|
||||
"install_popover_title": "Installer ce moteur",
|
||||
"install_popover_frozen_body": "Cette version packagée ne peut pas installer de moteurs : son environnement Python est en lecture seule et signé. Exécutez la commande ci-dessous dans une installation depuis les sources, ou passez au moteur Argos intégré.",
|
||||
"switch_to_argos": "Passer à Argos (intégré, hors ligne)",
|
||||
"copy_command": "Copier la commande",
|
||||
"install_cmd_copied": "Commande copiée",
|
||||
"dismiss_error": "Ignorer",
|
||||
"transcribe": "Transcrire",
|
||||
"translate": "Traduire",
|
||||
"generate": "Générer",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "सत्यापन निरस्त कर दिया गया"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "{{pkg}} इंस्टॉल करें",
|
||||
"needs_install_short": "इंस्टॉल आवश्यक",
|
||||
"install_popover_title": "यह इंजन इंस्टॉल करें",
|
||||
"install_popover_frozen_body": "यह पैकेज्ड बिल्ड इंजन इंस्टॉल नहीं कर सकता — इसका Python एनवायरनमेंट केवल-पढ़ने योग्य और साइन किया हुआ है। नीचे दिया गया कमांड सोर्स-से इंस्टॉल में चलाएँ, या साथ में आने वाले Argos इंजन पर स्विच करें।",
|
||||
"switch_to_argos": "Argos पर स्विच करें (अंतर्निहित, ऑफ़लाइन)",
|
||||
"copy_command": "कमांड कॉपी करें",
|
||||
"install_cmd_copied": "कमांड कॉपी हो गया",
|
||||
"dismiss_error": "खारिज करें",
|
||||
"transcribe": "प्रतिलेखन",
|
||||
"translate": "अनुवाद करें",
|
||||
"generate": "उत्पन्न करें",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Verifikasi dicabut"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Instal {{pkg}}",
|
||||
"needs_install_short": "Perlu instalasi",
|
||||
"install_popover_title": "Instal mesin ini",
|
||||
"install_popover_frozen_body": "Build paket ini tidak dapat memasang mesin — lingkungan Python-nya bersifat hanya-baca dan bertanda tangan. Jalankan perintah di bawah pada instalasi dari sumber, atau beralih ke mesin Argos bawaan.",
|
||||
"switch_to_argos": "Beralih ke Argos (bawaan, offline)",
|
||||
"copy_command": "Salin perintah",
|
||||
"install_cmd_copied": "Perintah disalin",
|
||||
"dismiss_error": "Tutup",
|
||||
"transcribe": "Transkripsikan",
|
||||
"translate": "Terjemahkan",
|
||||
"generate": "Hasilkan",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Verifica revocata"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Installa {{pkg}}",
|
||||
"needs_install_short": "Richiede installazione",
|
||||
"install_popover_title": "Installa questo motore",
|
||||
"install_popover_frozen_body": "Questa build pacchettizzata non può installare motori: il suo ambiente Python è di sola lettura e firmato. Esegui il comando qui sotto in un'installazione da sorgente, oppure passa al motore Argos incluso.",
|
||||
"switch_to_argos": "Passa ad Argos (incluso, offline)",
|
||||
"copy_command": "Copia comando",
|
||||
"install_cmd_copied": "Comando copiato",
|
||||
"dismiss_error": "Ignora",
|
||||
"transcribe": "Trascrivere",
|
||||
"translate": "Traduci",
|
||||
"generate": "Genera",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "検証が取り消されました"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "{{pkg}} をインストール",
|
||||
"needs_install_short": "インストールが必要",
|
||||
"install_popover_title": "このエンジンをインストール",
|
||||
"install_popover_frozen_body": "このパッケージ版はエンジンをインストールできません(Python 環境が読み取り専用で署名済みのため)。下のコマンドをソースからのインストールで実行するか、同梱の Argos エンジンに切り替えてください。",
|
||||
"switch_to_argos": "Argos に切り替え(同梱・オフライン)",
|
||||
"copy_command": "コマンドをコピー",
|
||||
"install_cmd_copied": "コマンドをコピーしました",
|
||||
"dismiss_error": "閉じる",
|
||||
"transcribe": "転写する",
|
||||
"translate": "翻訳する",
|
||||
"generate": "生成する",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "인증이 취소되었습니다."
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "{{pkg}} 설치",
|
||||
"needs_install_short": "설치 필요",
|
||||
"install_popover_title": "이 엔진 설치",
|
||||
"install_popover_frozen_body": "이 패키지 빌드는 엔진을 설치할 수 없습니다. Python 환경이 읽기 전용이며 서명되어 있기 때문입니다. 아래 명령을 소스 설치에서 실행하거나 기본 제공되는 Argos 엔진으로 전환하세요.",
|
||||
"switch_to_argos": "Argos로 전환(기본 제공, 오프라인)",
|
||||
"copy_command": "명령 복사",
|
||||
"install_cmd_copied": "명령이 복사됨",
|
||||
"dismiss_error": "닫기",
|
||||
"transcribe": "전사",
|
||||
"translate": "번역하다",
|
||||
"generate": "생성",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Verificatie ingetrokken"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "{{pkg}} installeren",
|
||||
"needs_install_short": "Installatie vereist",
|
||||
"install_popover_title": "Deze engine installeren",
|
||||
"install_popover_frozen_body": "Deze verpakte build kan geen engines installeren — de Python-omgeving is alleen-lezen en ondertekend. Voer de onderstaande opdracht uit in een broncode-installatie, of schakel over naar de meegeleverde Argos-engine.",
|
||||
"switch_to_argos": "Overschakelen naar Argos (meegeleverd, offline)",
|
||||
"copy_command": "Opdracht kopiëren",
|
||||
"install_cmd_copied": "Opdracht gekopieerd",
|
||||
"dismiss_error": "Sluiten",
|
||||
"transcribe": "Transcriberen",
|
||||
"translate": "Vertalen",
|
||||
"generate": "Genereer",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Weryfikacja odwołana"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Zainstaluj {{pkg}}",
|
||||
"needs_install_short": "Wymaga instalacji",
|
||||
"install_popover_title": "Zainstaluj ten silnik",
|
||||
"install_popover_frozen_body": "Ta spakowana wersja nie może instalować silników — jej środowisko Python jest tylko do odczytu i podpisane. Uruchom poniższe polecenie w instalacji ze źródeł lub przełącz się na dołączony silnik Argos.",
|
||||
"switch_to_argos": "Przełącz na Argos (wbudowany, offline)",
|
||||
"copy_command": "Kopiuj polecenie",
|
||||
"install_cmd_copied": "Skopiowano polecenie",
|
||||
"dismiss_error": "Odrzuć",
|
||||
"transcribe": "Transkrypcja",
|
||||
"translate": "Przetłumacz",
|
||||
"generate": "Wygeneruj",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Verificação revogada"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Instalar {{pkg}}",
|
||||
"needs_install_short": "Requer instalação",
|
||||
"install_popover_title": "Instalar este motor",
|
||||
"install_popover_frozen_body": "Esta versão empacotada não consegue instalar motores — o seu ambiente Python é somente leitura e assinado. Execute o comando abaixo numa instalação a partir do código-fonte, ou mude para o motor Argos incluído.",
|
||||
"switch_to_argos": "Mudar para o Argos (incluído, offline)",
|
||||
"copy_command": "Copiar comando",
|
||||
"install_cmd_copied": "Comando copiado",
|
||||
"dismiss_error": "Dispensar",
|
||||
"transcribe": "Transcrever",
|
||||
"translate": "Traduzir",
|
||||
"generate": "Gerar",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Проверка отменена"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Установить {{pkg}}",
|
||||
"needs_install_short": "Требуется установка",
|
||||
"install_popover_title": "Установить этот движок",
|
||||
"install_popover_frozen_body": "Эта упакованная сборка не может устанавливать движки — её окружение Python доступно только для чтения и подписано. Выполните команду ниже в установке из исходного кода или переключитесь на встроенный движок Argos.",
|
||||
"switch_to_argos": "Переключиться на Argos (встроенный, офлайн)",
|
||||
"copy_command": "Копировать команду",
|
||||
"install_cmd_copied": "Команда скопирована",
|
||||
"dismiss_error": "Закрыть",
|
||||
"transcribe": "Расшифровать",
|
||||
"translate": "Переводить",
|
||||
"generate": "Генерировать",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Verifieringen återkallad"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Installera {{pkg}}",
|
||||
"needs_install_short": "Kräver installation",
|
||||
"install_popover_title": "Installera denna motor",
|
||||
"install_popover_frozen_body": "Den här paketerade versionen kan inte installera motorer – dess Python-miljö är skrivskyddad och signerad. Kör kommandot nedan i en källkodsinstallation, eller växla till den medföljande Argos-motorn.",
|
||||
"switch_to_argos": "Växla till Argos (medföljer, offline)",
|
||||
"copy_command": "Kopiera kommando",
|
||||
"install_cmd_copied": "Kommando kopierat",
|
||||
"dismiss_error": "Avfärda",
|
||||
"transcribe": "Transkribera",
|
||||
"translate": "Översätt",
|
||||
"generate": "Generera",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "เพิกถอนการยืนยันแล้ว"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "ติดตั้ง {{pkg}}",
|
||||
"needs_install_short": "ต้องติดตั้ง",
|
||||
"install_popover_title": "ติดตั้งเอนจินนี้",
|
||||
"install_popover_frozen_body": "บิลด์แบบแพ็กเกจนี้ติดตั้งเอนจินไม่ได้ เนื่องจากสภาพแวดล้อม Python เป็นแบบอ่านอย่างเดียวและมีลายเซ็น ให้รันคำสั่งด้านล่างในการติดตั้งแบบซอร์สโค้ด หรือสลับไปใช้เอนจิน Argos ที่มาพร้อมกับโปรแกรม",
|
||||
"switch_to_argos": "สลับไปใช้ Argos (มาพร้อมกับโปรแกรม ออฟไลน์)",
|
||||
"copy_command": "คัดลอกคำสั่ง",
|
||||
"install_cmd_copied": "คัดลอกคำสั่งแล้ว",
|
||||
"dismiss_error": "ปิด",
|
||||
"transcribe": "ถอดเสียง",
|
||||
"translate": "แปล",
|
||||
"generate": "สร้าง",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Doğrulama iptal edildi"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "{{pkg}} yükle",
|
||||
"needs_install_short": "Kurulum gerekli",
|
||||
"install_popover_title": "Bu motoru yükle",
|
||||
"install_popover_frozen_body": "Bu paketlenmiş sürüm motor yükleyemez — Python ortamı salt okunur ve imzalıdır. Aşağıdaki komutu kaynaktan kurulumda çalıştırın ya da yerleşik Argos motoruna geçin.",
|
||||
"switch_to_argos": "Argos'a geç (yerleşik, çevrimdışı)",
|
||||
"copy_command": "Komutu kopyala",
|
||||
"install_cmd_copied": "Komut kopyalandı",
|
||||
"dismiss_error": "Kapat",
|
||||
"transcribe": "Metne dönüştür",
|
||||
"translate": "Çevir",
|
||||
"generate": "Oluştur",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Перевірку скасовано"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Встановити {{pkg}}",
|
||||
"needs_install_short": "Потрібне встановлення",
|
||||
"install_popover_title": "Встановити цей рушій",
|
||||
"install_popover_frozen_body": "Ця пакетна збірка не може встановлювати рушії — її середовище Python лише для читання та підписане. Виконайте команду нижче у встановленні з вихідного коду або перемкніться на вбудований рушій Argos.",
|
||||
"switch_to_argos": "Перемкнутися на Argos (вбудований, офлайн)",
|
||||
"copy_command": "Копіювати команду",
|
||||
"install_cmd_copied": "Команду скопійовано",
|
||||
"dismiss_error": "Закрити",
|
||||
"transcribe": "Транскрибувати",
|
||||
"translate": "Перекласти",
|
||||
"generate": "Генерувати",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "Đã thu hồi xác minh"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "Cài đặt {{pkg}}",
|
||||
"needs_install_short": "Cần cài đặt",
|
||||
"install_popover_title": "Cài đặt công cụ này",
|
||||
"install_popover_frozen_body": "Bản đóng gói này không thể cài công cụ — môi trường Python của nó chỉ đọc và đã ký. Hãy chạy lệnh bên dưới trong bản cài từ mã nguồn, hoặc chuyển sang công cụ Argos tích hợp sẵn.",
|
||||
"switch_to_argos": "Chuyển sang Argos (tích hợp, ngoại tuyến)",
|
||||
"copy_command": "Sao chép lệnh",
|
||||
"install_cmd_copied": "Đã sao chép lệnh",
|
||||
"dismiss_error": "Bỏ qua",
|
||||
"transcribe": "Phiên âm",
|
||||
"translate": "Dịch",
|
||||
"generate": "Tạo",
|
||||
|
||||
@@ -617,6 +617,14 @@
|
||||
"consent_revoked": "验证已撤销"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "安装 {{pkg}}",
|
||||
"needs_install_short": "需要安装",
|
||||
"install_popover_title": "安装此引擎",
|
||||
"install_popover_frozen_body": "此打包版本无法安装引擎——其 Python 环境为只读且已签名。请在源码安装环境中运行下面的命令,或切换到内置的 Argos 引擎。",
|
||||
"switch_to_argos": "切换到 Argos(内置,离线)",
|
||||
"copy_command": "复制命令",
|
||||
"install_cmd_copied": "已复制命令",
|
||||
"dismiss_error": "关闭",
|
||||
"transcribe": "转录",
|
||||
"translate": "翻译",
|
||||
"generate": "生成",
|
||||
|
||||
@@ -659,6 +659,14 @@
|
||||
"consent_revoked": "驗證已撤銷"
|
||||
},
|
||||
"dub": {
|
||||
"install_engine_pkg": "安裝 {{pkg}}",
|
||||
"needs_install_short": "需要安裝",
|
||||
"install_popover_title": "安裝此引擎",
|
||||
"install_popover_frozen_body": "此打包版本無法安裝引擎——其 Python 環境為唯讀且已簽章。請在原始碼安裝環境中執行下方指令,或切換至內建的 Argos 引擎。",
|
||||
"switch_to_argos": "切換至 Argos(內建,離線)",
|
||||
"copy_command": "複製指令",
|
||||
"install_cmd_copied": "已複製指令",
|
||||
"dismiss_error": "關閉",
|
||||
"transcribe": "轉錄",
|
||||
"translate": "翻譯",
|
||||
"generate": "產生",
|
||||
|
||||
@@ -93,6 +93,7 @@ export default function DubTab(props) {
|
||||
const setDubInstruct = useAppStore((s) => s.setDubInstruct);
|
||||
const dubTracks = useAppStore((s) => s.dubTracks);
|
||||
const dubError = useAppStore((s) => s.dubError);
|
||||
const setDubError = useAppStore((s) => s.setDubError);
|
||||
const dubFailure = useAppStore((s) => s.dubFailure);
|
||||
const dubProgress = useAppStore((s) => s.dubProgress);
|
||||
const isTranslating = useAppStore((s) => s.isTranslating);
|
||||
@@ -242,8 +243,22 @@ export default function DubTab(props) {
|
||||
}, [refreshEngines]);
|
||||
const activeEngineEntry = engines.find((e) => e.id === translateProvider);
|
||||
const activeEngineUnavailable = activeEngineEntry && !activeEngineEntry.installed;
|
||||
// Changing the translation engine is a corrective action — clear any stale
|
||||
// translate/pipeline error banner so it doesn't outlive the choice that
|
||||
// caused it (same class-fix as clearing on a new translate attempt). Covers
|
||||
// both the <select> and the popover's "Switch to Argos" path.
|
||||
const handleSelectTranslateProvider = useCallback(
|
||||
(id) => {
|
||||
setDubError('');
|
||||
setTranslateProvider(id);
|
||||
},
|
||||
[setDubError, setTranslateProvider],
|
||||
);
|
||||
const handleInstallEngine = async (engineId) => {
|
||||
if (!engineId || enginesSandboxed) return;
|
||||
// Installing the missing package is corrective too — drop the banner that
|
||||
// told the user to install it in the first place.
|
||||
setDubError('');
|
||||
setEngineInstalling(engineId);
|
||||
const progressToast = toast.loading(t('dub.install_progress', { engine: engineId }));
|
||||
try {
|
||||
@@ -526,7 +541,7 @@ export default function DubTab(props) {
|
||||
engineInstalling={engineInstalling}
|
||||
activeEngineEntry={activeEngineEntry}
|
||||
engines={engines}
|
||||
setTranslateProvider={setTranslateProvider}
|
||||
setTranslateProvider={handleSelectTranslateProvider}
|
||||
setTranslateQuality={setTranslateQuality}
|
||||
llmEndpoint={llmEndpoint}
|
||||
multiLangMode={multiLangMode}
|
||||
@@ -594,6 +609,7 @@ export default function DubTab(props) {
|
||||
incrementalPlan={incrementalPlan}
|
||||
dubError={dubError}
|
||||
dubFailure={dubFailure}
|
||||
onDismissError={() => setDubError('')}
|
||||
exportTracks={exportTracks}
|
||||
setExportTracks={setExportTracks}
|
||||
dubSegments={dubSegments}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { fireEvent, render, screen, act } from '@testing-library/react';
|
||||
import i18n from '../i18n';
|
||||
|
||||
import DubFooter from '../components/dub/DubFooter';
|
||||
|
||||
const t = i18n.t.bind(i18n);
|
||||
|
||||
function makeProps(over = {}) {
|
||||
return {
|
||||
t,
|
||||
dubStep: 'editing',
|
||||
dubTracks: [],
|
||||
incrementalPlan: null,
|
||||
dubError: 'TRANSLATION FAILED: 400 — deep_translator not installed',
|
||||
dubFailure: null,
|
||||
onDismissError: vi.fn(),
|
||||
exportTracks: {},
|
||||
setExportTracks: vi.fn(),
|
||||
dubSegments: [],
|
||||
translateQuality: 'fast',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DubFooter — dismissable / auto-clearing translation error banner', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders a dismiss button that clears the error (× → onDismissError)', () => {
|
||||
const onDismissError = vi.fn();
|
||||
render(<DubFooter {...makeProps({ onDismissError })} />);
|
||||
expect(screen.getByText(/TRANSLATION FAILED/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: t('dub.dismiss_error') }));
|
||||
expect(onDismissError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('auto-clears the banner after the timeout while editing', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismissError = vi.fn();
|
||||
render(<DubFooter {...makeProps({ onDismissError, dubStep: 'editing' })} />);
|
||||
expect(onDismissError).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(12000);
|
||||
});
|
||||
expect(onDismissError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT auto-clear while generating (live per-segment errors must persist)', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismissError = vi.fn();
|
||||
render(<DubFooter {...makeProps({ onDismissError, dubStep: 'generating' })} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
expect(onDismissError).not.toHaveBeenCalled();
|
||||
// …but the × is still available for a manual dismiss.
|
||||
fireEvent.click(screen.getByRole('button', { name: t('dub.dismiss_error') }));
|
||||
expect(onDismissError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('no banner, no dismiss button when there is no error', () => {
|
||||
render(<DubFooter {...makeProps({ dubError: '' })} />);
|
||||
expect(screen.queryByRole('button', { name: t('dub.dismiss_error') })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { createRef } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import i18n from '../i18n';
|
||||
|
||||
// Heavy children we don't exercise here — keep the render focused on the
|
||||
// Engine selector's install affordance.
|
||||
vi.mock('../components/WaveformTimeline', () => ({ default: () => <div data-testid="wf" /> }));
|
||||
vi.mock('../components/MultiLangPicker', () => ({ default: () => <div data-testid="mlp" /> }));
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn(), loading: vi.fn() },
|
||||
}));
|
||||
const openExternal = vi.fn();
|
||||
vi.mock('../api/external', () => ({ openExternal: (...a) => openExternal(...a) }));
|
||||
const copyText = vi.fn().mockResolvedValue(true);
|
||||
vi.mock('../utils/copyText', () => ({ copyText: (...a) => copyText(...a) }));
|
||||
|
||||
import DubLeftColumn from '../components/dub/DubLeftColumn';
|
||||
|
||||
const t = i18n.t.bind(i18n);
|
||||
|
||||
const GOOGLE = {
|
||||
id: 'google',
|
||||
display_name: 'Google Translate (Online, Free)',
|
||||
installed: false,
|
||||
pip_package: 'deep_translator',
|
||||
install_command: 'uv pip install deep_translator',
|
||||
};
|
||||
|
||||
function makeProps(over = {}) {
|
||||
return {
|
||||
hasDubbedTrack: false,
|
||||
t,
|
||||
i18n,
|
||||
previewMode: 'original',
|
||||
setPreviewMode: vi.fn(),
|
||||
dubTracks: [],
|
||||
videoSrc: '',
|
||||
waveformRef: createRef(),
|
||||
dubJobId: 'job1',
|
||||
dubSegments: [{ id: '1', text: 'hi' }],
|
||||
timelineOnsets: [],
|
||||
timelineSelSegId: null,
|
||||
setTimelineSelSegId: vi.fn(),
|
||||
incrementalPlan: null,
|
||||
segmentMoveResize: vi.fn(),
|
||||
segmentDelete: vi.fn(),
|
||||
onTimelinePreviewSegment: vi.fn(),
|
||||
dubStep: 'editing',
|
||||
dubProgress: { current: 0, total: 0, text: '' },
|
||||
fmtDur: (s) => `${s}s`,
|
||||
genElapsed: 0,
|
||||
genRemaining: null,
|
||||
speakerClones: {},
|
||||
setDubSegments: vi.fn(),
|
||||
profiles: [],
|
||||
settingsOpen: true,
|
||||
setSettingsOpen: vi.fn(),
|
||||
dubLang: 'Spanish',
|
||||
dubLangCode: 'es',
|
||||
translateQuality: 'fast',
|
||||
activeEngineUnavailable: true,
|
||||
translateProvider: 'google',
|
||||
dubInstruct: '',
|
||||
setDubInstruct: vi.fn(),
|
||||
handleTranslateAll: vi.fn(),
|
||||
isTranslating: false,
|
||||
hasAnyTranslation: false,
|
||||
handleCleanupSegments: vi.fn(),
|
||||
setDubLang: vi.fn(),
|
||||
setDubLangCode: vi.fn(),
|
||||
dubDialect: '',
|
||||
setDubDialect: vi.fn(),
|
||||
enginesSandboxed: false,
|
||||
handleInstallEngine: vi.fn(),
|
||||
engineInstalling: null,
|
||||
activeEngineEntry: GOOGLE,
|
||||
engines: [GOOGLE],
|
||||
setTranslateProvider: vi.fn(),
|
||||
setTranslateQuality: vi.fn(),
|
||||
llmEndpoint: { available: true },
|
||||
multiLangMode: false,
|
||||
setMultiLangMode: vi.fn(),
|
||||
multiLangs: [],
|
||||
setMultiLangs: vi.fn(),
|
||||
editSegments: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DubLeftColumn — translation-engine install affordance', () => {
|
||||
beforeEach(() => {
|
||||
openExternal.mockClear();
|
||||
copyText.mockClear();
|
||||
});
|
||||
|
||||
it('FROM-SOURCE lane: renders a highlighted Install button wired to handleInstallEngine', () => {
|
||||
const handleInstallEngine = vi.fn();
|
||||
render(<DubLeftColumn {...makeProps({ enginesSandboxed: false, handleInstallEngine })} />);
|
||||
|
||||
// Highlighted accent button (not the muted chip): brand-accent bg class.
|
||||
const btn = screen.getByRole('button', { name: /install deep_translator/i });
|
||||
expect(btn.className).toMatch(/bg-\[#d3869b\]/);
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(handleInstallEngine).toHaveBeenCalledWith('google');
|
||||
});
|
||||
|
||||
it('FROZEN lane: opens a popover with the copy-command + Switch-to-Argos + Docs, and NEVER installs', () => {
|
||||
const handleInstallEngine = vi.fn();
|
||||
const setTranslateProvider = vi.fn();
|
||||
render(
|
||||
<DubLeftColumn
|
||||
{...makeProps({ enginesSandboxed: true, handleInstallEngine, setTranslateProvider })}
|
||||
/>,
|
||||
);
|
||||
|
||||
// No from-source install button in the frozen lane.
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /install deep_translator/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The highlighted trigger opens the escape-hatch popover.
|
||||
const trigger = screen.getByRole('button', { name: /needs install/i });
|
||||
expect(trigger.className).toMatch(/bg-\[#d3869b\]/);
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
// Exact install command, single-sourced from install_command.
|
||||
expect(within(dialog).getByText('uv pip install deep_translator')).toBeInTheDocument();
|
||||
|
||||
// Copy-to-clipboard works.
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: t('dub.copy_command') }));
|
||||
expect(copyText).toHaveBeenCalledWith('uv pip install deep_translator');
|
||||
|
||||
// Docs deeplink opens via the Tauri shell.open path.
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: t('dub.open_docs') }));
|
||||
expect(openExternal).toHaveBeenCalledWith(expect.stringContaining('translation-engines.md'));
|
||||
|
||||
// Guaranteed offline escape hatch: switch to Argos.
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /switch to argos/i }));
|
||||
expect(setTranslateProvider).toHaveBeenCalledWith('argos');
|
||||
|
||||
// Critical: the frozen lane must never trigger an install.
|
||||
expect(handleInstallEngine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changing the engine <select> fires setTranslateProvider (the error-clearing corrective action)', () => {
|
||||
const setTranslateProvider = vi.fn();
|
||||
const engines = [
|
||||
GOOGLE,
|
||||
{ id: 'argos', display_name: 'Argos', installed: true, install_command: null },
|
||||
];
|
||||
render(<DubLeftColumn {...makeProps({ engines, setTranslateProvider })} />);
|
||||
// The engine <select> is the only combobox whose current value is 'google'.
|
||||
const select = screen.getAllByRole('combobox').find((el) => el.value === 'google');
|
||||
expect(select).toBeTruthy();
|
||||
fireEvent.change(select, { target: { value: 'argos' } });
|
||||
expect(setTranslateProvider).toHaveBeenCalledWith('argos');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,10 @@ export const ERROR_DOCS: Record<string, string> = {
|
||||
|
||||
export const DEFAULT_DOCS = `${BASE}/docs/install/troubleshooting.md`;
|
||||
|
||||
// Deep-link for the Dub tab's "needs install" translation-engine popover.
|
||||
// Reuses BASE so it can't drift from the other GitHub-blob links above.
|
||||
export const TRANSLATION_ENGINES_DOCS = `${BASE}/docs/dubbing/translation-engines.md#installing-optional-translation-engines-from-source-vs-packaged-build`;
|
||||
|
||||
// Locked taxonomy keys — Phase 5 bug reporter consumes this exact set.
|
||||
// Adding a 6th class is a contract change; update the Python map at the
|
||||
// same time (`backend/core/error_docs_map.py`).
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Single-source install command for translation engines.
|
||||
|
||||
The proactive Install affordance in the Dub Engine selector (fed by
|
||||
``list_engines()['install_command']``) and the translate-time 400 error
|
||||
(dub_translate.py) must both read the SAME command string, so a user is never
|
||||
told two different things. These tests fail-before / pass-after the
|
||||
``translation_engines.install_command`` extraction and its use in the 400s.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
|
||||
from services import translation_engines as te
|
||||
|
||||
|
||||
def test_list_engines_emits_install_command():
|
||||
engines = {e["id"]: e for e in te.list_engines()}
|
||||
# deep_translator-backed online engines all share the same command.
|
||||
for eid in ("google", "deepl", "microsoft", "mymemory"):
|
||||
assert engines[eid]["install_command"] == "uv pip install deep_translator", eid
|
||||
assert engines["argos"]["install_command"] == "uv pip install argostranslate"
|
||||
assert engines["openai"]["install_command"] == "uv pip install openai"
|
||||
# NLLB rides on the core `transformers` dep — no separate install line.
|
||||
assert engines["nllb"]["install_command"] is None
|
||||
|
||||
|
||||
def test_install_command_helper_matches_registry():
|
||||
assert te.install_command("google") == "uv pip install deep_translator"
|
||||
assert te.install_command("nllb") is None
|
||||
assert te.install_command("does-not-exist") is None
|
||||
# Accepts a registry entry dict too (used by list_engines).
|
||||
assert te.install_command(te.get_engine("openai")) == "uv pip install openai"
|
||||
|
||||
|
||||
def _translate_400_body(monkeypatch, provider, missing_module):
|
||||
"""Force the optional dep to be unimportable, run one translate, return the
|
||||
400 JSON body. ``sys.modules[name] = None`` makes ``import name`` raise
|
||||
ImportError even when the package is actually installed — deterministic on
|
||||
dev + CI regardless of what's in the venv."""
|
||||
from api.routers.dub_translate import dub_translate
|
||||
from schemas.requests import TranslateRequest, TranslateSegment
|
||||
|
||||
monkeypatch.setitem(sys.modules, missing_module, None)
|
||||
req = TranslateRequest(
|
||||
segments=[TranslateSegment(id="1", text="hello world")],
|
||||
target_lang="es",
|
||||
provider=provider,
|
||||
source_lang="en",
|
||||
)
|
||||
resp = asyncio.run(dub_translate(req))
|
||||
assert resp.status_code == 400, resp
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def test_deep_translator_400_embeds_registry_install_command(monkeypatch):
|
||||
body = _translate_400_body(monkeypatch, "google", "deep_translator")
|
||||
cmd = te.install_command("google")
|
||||
assert cmd == "uv pip install deep_translator"
|
||||
# The exact command from list_engines appears verbatim in the 400 — they
|
||||
# cannot drift.
|
||||
assert cmd in body["error"], body
|
||||
|
||||
|
||||
def test_argos_400_embeds_registry_install_command(monkeypatch):
|
||||
body = _translate_400_body(monkeypatch, "argos", "argostranslate")
|
||||
cmd = te.install_command("argos")
|
||||
assert cmd == "uv pip install argostranslate"
|
||||
assert cmd in body["error"], body
|
||||
Reference in New Issue
Block a user