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:
Palash Debnath
2026-07-01 11:16:13 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent 66ad03948b
commit 522bbddccf
35 changed files with 827 additions and 19 deletions
+20
View File
@@ -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
+14 -4
View File
@@ -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)."
)
+18
View File
@@ -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
+92
View File
@@ -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.
+21
View File
@@ -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
+3
View File
@@ -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[];
+36 -4
View File
@@ -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>
)}
+129 -10
View File
@@ -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>
+5
View File
@@ -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) => ({
+8
View File
@@ -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": "إنشاء",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "उत्पन्न करें",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "生成する",
+8
View File
@@ -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": "생성",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "Генерировать",
+8
View File
@@ -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",
+8
View File
@@ -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": "สร้าง",
+8
View File
@@ -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",
+8
View File
@@ -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": "Генерувати",
+8
View File
@@ -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",
+8
View File
@@ -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": "生成",
+8
View File
@@ -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": "產生",
+17 -1
View File
@@ -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}
+68
View File
@@ -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');
});
});
+4
View File
@@ -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`).
+71
View File
@@ -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