Compare commits

..
19 Commits
Author SHA1 Message Date
254f071b45 fix(docker): ship alembic.ini in the image, add an image-level HEALTHCHECK (#1080)
Migrations in Docker fell back to the additive-column self-heal because
alembic.ini was never copied; the real migration chain now runs. The
HEALTHCHECK covers plain docker-run (compose files keep their own), with
a start period sized for first-boot schema creation. Docs example tag
freshened.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:17:57 +05:30
d1522cbba0 release: freeze v0.3.17 — version bump, lockfiles, changelog (#1079)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:05:58 +05:30
d3e88c0f3e fix(scripts): desktop-fresh kill guard referenced the wrong dry-run flag (#1078)
The kill-before-wipe block used DRY_RUN; the script's flag is dryRun —
any run with a live instance crashed with ReferenceError before wiping.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:40:51 +05:30
fb0508f4f6 fix(shell): deep health probe before attaching to a running backend; scripts kill before wiping (#1077)
A backend that keeps running while its install is deleted or replaced
underneath it still answers /health and /system/info from memory — the
launcher's version check passed and the UI attached to a process that
500s every DB-touching route (raw errors without CORS headers, so the
webview reports access-control failures). The attach path now requires a
DB-touching probe (/profiles) to return an actual 200 status line, and
replaces the squatter otherwise — the status line is parsed explicitly
because the raw HTTP helper previously returned 500 bodies as Ok.
desktop-prod/desktop-fresh now terminate our own running processes
(bundle, dev binary, app-scoped port-3900 listener) before wiping, which
is how the zombie was produced.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:58 +05:30
855a72038b docs(changelog): unreleased entry for #1074 (#1075)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:47:51 +05:30
e0a7b2202d fix(ui): default UI scale is 100% — native size out of the box (#1074)
New installs rendered at 130% zoom, which read as oversized on typical
displays. Fresh sessions now start at 100%; anyone who already picked a
scale keeps it (uiScale is persisted and wins over the default).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:34:56 +05:30
f7be62207e docs(changelog): unreleased entries for #1071 and #1072 (#1073)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:11:53 +05:30
7762bc48bd feat(settings): Engines & Models go tabbed and compact — strict two-line rows, aligned columns (#1072)
Settings → Engines collapses from three stacked full matrices (where one
engine row could sprawl to 5+ lines and fill a viewport) into ONE section
with a TTS / ASR / LLM tab strip (the matrix's Radix Segmented — roving
tabindex + arrow keys, active engine named in each tab caption). The single
mounted matrix still issues exactly one GET /engines + one GET /model/loaded
per Settings open; switching tabs re-slices the fetched payload with no
refetch. openSettingsTab('engines') deep-linking is unchanged (nothing in
the app targets a specific family — audited).

Every engine row is now strictly two lines inside a fixed h-16 shell:
line 1 = EngineMark + truncated display name (full name via title, never
wraps) + active/in-memory badges; line 2 = engine id, cloning chip,
curated-model picker, one-line truncated hints (full text via title).
Header and rows share one grid template
(minmax(0,1fr) 108px 176px 92px 232px) so the STATUS / GPU COMPAT /
ISOLATION / ACTIONS columns align on every row; actions sit right-aligned
and vertically centered across both lines. Below 880px the three meta cells
re-place onto the row's second line (same DOM nodes) instead of forcing a
horizontal scroll. Unavailable-row details (reason, install hint, last
error, setup snippet) move out of the row into an aria-expanded expansion
panel that opens BELOW it, so sibling rows never lose alignment. The
previously hardcoded column labels are i18n'd (engines.col*).

Settings → Models already has its natural grouping as the role filter
strip (All/TTS/ASR/… with counts) + search over one list, so no tabs were
invented there; its rows get the same compactness treatment — 5px vertical
padding + 52px min-height two-line rows (virtualizer estimate updated),
with title attributes carrying the full label/repo text past the ellipsis.

Tests: EnginesTab suite rewritten for the tabbed layout (tab strip renders,
switching families doesn't refetch, single /engines + /model/loaded probes,
Use-on-ASR flows through the tab); matrix suite updated for the expansion
panel (reason/last-error/setup-snippet live behind the Why-unavailable
toggle) and extended with layout regressions: fixed-height two-line shell,
name truncation + title, header/rows sharing identical grid tracks,
panel open/close as a sibling below the row. 1139 frontend tests pass;
typecheck:ci, oxlint, oxfmt and vite build are clean.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:58:27 +05:30
5562aa16a7 feat(setup): media tools become invisible — bundled by default, controllable in Settings (#1071)
Most users should never learn what ffmpeg is. The Setup Wizard's SYSTEM
PREFLIGHT stops listing FFmpeg / FFprobe / yt-dlp as user-installed
requirements ("brew install ffmpeg…"): they are internal dependencies the
app provisions for itself. Genuine user facts (OS, RAM, disk, GPU,
network, Python) are untouched.

Backend
- New services/media_tools.py: per-tool status {version, path, origin:
  sidecar|bundled|system|custom}; background acquisition of a pinned,
  SHA-256-verified static ffmpeg+ffprobe build (immutable-commit fetch
  from the same upstream the static-ffmpeg pip package uses — that
  package itself was audited and rejected: mutable raw/main URL, no
  checksums, writes into site-packages); binaries are `-version`-probed
  via the existing _binary_runs before being trusted, installed under
  DATA_DIR (update-surviving, frozen-build-safe), zero new Python deps.
- ffmpeg_utils resolution chain gains the acquired-bundled tier — and
  ffprobe finally has a bundled tier at all (imageio-ffmpeg ships none),
  closing the source-install gap.
- New /media-tools router (loopback-gated, same contract as
  /system/set-env): status, acquire, {tool}/custom-path | use-system |
  restore, ytdlp/update | restore. Overrides persist via the existing
  env.FFMPEG_PATH / env.FFPROBE_PATH prefs convention — one store, no
  competing controls.
- yt-dlp updates: audited in-venv pip/uv upgrade and rejected (venv is
  uv-managed with no pip; yt-dlp is a locked dep, so the updater's
  --inexact drift sync would revert it). Instead the newest wheel —
  verified against PyPI's own sha256 — lands in a DATA_DIR overlay
  prepended to sys.path at startup: survives app updates, works in
  frozen builds, and "Restore tested version" is just deleting the
  overlay. Gallery now runs yt-dlp via `python -m yt_dlp` (module, not
  PATH) so the CLI can never be a user-install task either.
- /setup/preflight drops the three tool rows, carries a media_tools
  verdict, and self-heals: kicks the bundled download in the background
  when no tier resolves (never re-fires after a failure — the wizard's
  card owns Retry). diagnose + the ffmpeg-missing notification now point
  at Settings → Audio tools instead of package managers.

Frontend
- Wizard: new MediaEngineCard — renders NOTHING when the engine is ready,
  a one-line progress while acquiring, and only on failure an actionable
  card (Retry / Use a system copy / Choose file…).
- Settings → Audio tools (new category, System group): FFmpeg + FFprobe
  rows with version, path, origin badge, Use system copy / Choose file… /
  Restore bundled, header-level "Update bundled build"; yt-dlp row with
  Update + Restore tested version (+ restart affordance). Package-manager
  commands appear only as copyable prose, never executed.
- The FFmpeg-path override moved out of Settings → Network (pointer row
  deep-links to Audio tools; no second writer of env.FFMPEG_PATH).
  Notifications gain a settings-tab action type.
- All strings i18n (en + defaultValue), a11y labels on every control.

Tests: 29 new backend (origin classification, checksum/size/probe
rejection, override persistence, overlay update/restore, router gating +
route-shadowing) + preflight contract tests (tool rows gone, verdict
present, auto-acquire fires once); 14 new frontend (wizard hide/progress/
failure-card, Audio tools rows/badges/actions). Route snapshot
regenerated. Docs (macos/linux install, troubleshooting §7b) describe the
new reality in the same commit.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:22:49 +05:30
34c8a33628 fix(scripts): desktop-prod builds clean, desktop-fresh emulates a brand-new machine (#1070)
desktop-prod fixes:
- `tauri build --debug` used to produce every bundle and THEN exit 1 at the
  updater-artifact signing step (no TAURI_SIGNING_PRIVATE_KEY on dev
  machines); the script papered over it with a blanket "non-fatal bundle
  error" grep that also swallowed real bundling failures. Local emulation
  builds now pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'`
  and only build the bundle the script launches (--bundles app / appimage,
  --no-bundle on Windows), so the build exits 0. Any nonzero exit now FAILS
  the script — the sole tolerated case is a specifically-detected
  linuxdeploy/FUSE failure on Linux when the raw debug binary was produced.
- The HF cache wipe ran `rm -rf ~/.cache/huggingface` on macOS/Linux — the
  SHARED global cache (backend/core/config.py only relocates it on Windows),
  deleting models unrelated to OmniVoice. Non-app-scoped cache paths are now
  kept with a "models will be reused" notice; FRESH_NUKE_HF=1 opts in.
- Honest clean marks (removed ✓ / already-clean ○ instead of ✗ for success),
  `open -n` always (plain `open` focused a stale running instance instead of
  launching the freshly built one), stale-AppImage removal on Linux.

New `bun desktop-fresh` (+ desktop-fresh:run), macOS-only with explicit
refusal elsewhere: true new-user emulation.
- Blank slate: everything desktop-prod cleans PLUS the traces that survive a
  reinstall + data wipe — ~/Library/WebKit (webview localStorage), Caches,
  HTTPStorages*, Preferences plist (+ defaults delete), Saved Application
  State. Per-path found/removed/absent status with sizes; --dry-run prints
  the full plan without touching anything.
- Dev-machine camouflage: launches by direct exec of the bundle's Mach-O
  (which inherits env — `open` hands off to launchd and drops it) with PATH
  stripped of /opt/homebrew/{bin,sbin} + /usr/local/bin and HF_TOKEN /
  HUGGING_FACE_HUB_TOKEN / HF_HOME / HF_HUB_CACHE / HF_ENDPOINT /
  OMNIVOICE_* unset, and prints a banner of what is hidden.

Shared pure helpers live in scripts/desktop-common.mjs, covered by 9 node
tests (tests/frontend/desktopScripts.test.mjs): every cleanable path is
app-scoped and under $HOME, the PATH/env sanitizers strip exactly the
intended entries, and the build args carry the updater-artifacts-off config.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:53:47 +05:30
17e2bb5ed5 docs(changelog): unreleased entry for #1067 (#1068)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:03:04 +05:30
fbac0de817 fix(dub): interrupted dub sessions no longer trap the app in an eternal spinner on relaunch (#1067)
The omni_ui session persisted dubStep verbatim, including in-flight values
(uploading/transcribing/generating/stopping). Quitting or crashing mid-dub
froze that step into localStorage, and every relaunch restored a wait on
work that died with the process — a blank Dub pane with an eternal spinner
that even reinstalling couldn't clear (the webview's localStorage survives).
Restores now clamp to settled states: editing when the session has segments,
idle otherwise; unknown/corrupt values are treated as transient.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:50:13 +05:30
2e71cc3744 docs(changelog): unreleased entries for #1058-#1064 (#1065)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 04:35:07 +05:30
e816a2c24e fix(settings): make provider/token panels honest — real Test now, gated probes, MCP bindings i18n + confirm (#1064)
HuggingFace token (ApiKeysPanel):
- "Test now" actually re-runs whoami: GET /api/settings/hf-token/state gains
  ?fresh=1 which drops the resolver's 300s validation cache (the invalidate
  hook existed but was never wired to any endpoint), so a fixed network or
  rotated token no longer shows a stale verdict for up to 5 minutes. Plain
  panel mounts keep the cache.
- Initial load renders a "Checking token sources…" placeholder instead of
  flashing a false amber "not set" for all three sources.
- Source rows are now a valid ARIA list (the old role="table" had rows with
  no cells, hiding the status from screen readers).
- Enter in the token input respects the in-flight guard the Save button
  already had (no duplicate POSTs).

LLM Providers:
- Test / Fetch models abort when the implicit save fails, instead of probing
  the previously-stored config and pairing a green "Test ok" badge with a
  save error.
- A failed initial load now offers a Retry button instead of dead-ending
  until the panel remounts.

LLM Skills: the per-skill provider Select carries an accessible name
("Provider for <skill>") instead of announcing as an unlabeled combobox.

MCP voice bindings:
- All user-facing strings go through i18n (the panel was the only Settings
  surface with hardcoded English throughout).
- First-run guidance moved out of per-row hints (which never rendered with
  zero bindings and duplicated per row) into the section header + an
  InfoHint that links to docs/mcp.md; an empty state invites the first add.
- Delete asks for confirmation via the shared askConfirm, disables the row's
  button while in flight, and re-syncs the list even when the DELETE fails
  (a 404 row no longer lingers on screen).
- The add row exposes the optional label the API already accepted (the row
  title rendered b.label without any way to set it); default_engine stays
  MCP-side-only and is documented as such.
- First component test file for the panel (load/empty/add/delete/error/a11y).

Tests: backend fail-before/pass-after for the fresh=1 cache bust; new
frontend coverage for every behavioral change above.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 04:21:47 +05:30
fd083749c6 fix(settings): network & privacy panels — clearable proxy after reload, HF-mirror panel never vanishes, guarded remote-backend save, honest privacy claims (#1063)
Settings → Network / Models / Sharing / Privacy / OpenAPI fixes:

- NetworkTab: a proxy persisted in a previous session can now be cleared —
  the Clear button and "Set" badge derive from the backend-persisted value
  (sysInfo.proxy_url), not only from a save in the current session. Proxy row
  copy now matches its real semantics ("Applies now" badge; desc/toast no
  longer claim a restart is needed or leak yt-dlp jargon — reworded in all
  21 locales). FFmpeg path placeholder is platform-appropriate instead of
  Windows-only on every OS.
- HFMirrorPanel: the panel no longer disappears when the initial GET fails —
  the section shell always renders, with a loading state and an error +
  Retry affordance. Saving now toasts, the active preset is marked
  (aria-pressed), and the custom-URL row is labelled "Custom mirror URL"
  instead of raw HF_ENDPOINT jargon (env var moved to the row note).
- RemoteBackendPanel: full i18n (was 100% hardcoded English); Save & reload
  now validates the URL (http/https, parseable) and asks for confirmation
  before saving a URL that hasn't passed a connection test — a typo'd base
  no longer bricks every API call after reload. Dropped the contradictory
  "Restart required" badge (saving reloads the app itself; description says
  so). docs/remote-gpu.md updated to match (docs-sync).
- PrivacyTab: the "Network calls" row no longer shows the green "Offline
  translator" assurance when the backend is down or reports 'unknown' —
  green is reserved for confirmed-offline providers (nllb/argos/
  libretranslate), everything unconfirmed shows a neutral "Unknown" badge.
  The online-translator warning now deep-links to Translation settings.
- OpenApiPanel: a failed clipboard copy toasts an error instead of silence.
- a11y: all five text inputs across these panels now carry accessible names
  (aria-label), previously announced only by their vanishing placeholders.

Tests: new colocated suites for NetworkTab, HFMirrorPanel,
RemoteBackendPanel, PrivacyTab; OpenApiPanel suite extended with copy
success/failure. Frontend suite 140 files / 1061 tests green; i18n parity
probes green (new keys en-only with defaultValue, reworded keys updated in
every locale).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 04:08:41 +05:30
b603b9f78d fix(settings): factory reset covers all prefs, guarded log clearing, temp reclaim, full Performance i18n (#1061)
Settings system-group cleanup — every fix keeps existing behavior contracts
and adds a fail-before/pass-after regression test:

- Factory reset now does what it promises: clears every locally-persisted
  preference via a single registry (utils/prefKeys.js) instead of only the
  zustand blob — nav-rail side, capture live-typing, stories speed, logs
  footer state, last settings category, dismissed tips, donate prompts, and
  the legacy omni_ui blob included. User data and connection state
  (omni_transcriptions, ov_backend_url, ov_api_key) are explicitly preserved,
  and prefKeys.test.js scans the source tree so any future localStorage key
  must be categorized or CI fails. The failure toast now carries the actual
  error message.
- Disk-usage "Clear logs" is confirm-gated with the same wording as
  Settings → Logs — it truncates the crash log (the bug-report artifact), so
  it can no longer be a single stray click.
- Temporary files got a reclaim action: a confirmed "Clear temp files"
  button backed by POST /api/settings/storage/temp/clear, which deletes only
  the omnivoice* entries in the OS temp dir (symlinks unlinked, never
  followed) and invalidates the cached report.
- Performance panel goes through i18n end to end (title, row, note, hint,
  errors, aria-label) — it was the last fully hardcoded panel; the
  non-Windows subtitle now reads "Windows only — not needed on this
  platform" instead of "not applicable".
- History retention: GET failures now surface an alert and hold Save until
  a load succeeds (404 from older backends stays silent), Enter saves, the
  dead !res.ok branch is gone, and the bespoke button is the shared Button.
- Logs tab: "Open folder" reveals the log file, "Copy visible log" copies
  the tail, the viewer autoscrolls to the newest lines, and the scroll box
  is keyboard-focusable (role=log) with a labelled source switcher.
- Storage paths: the app-data row is labelled "App data stored at" (it was
  borrowing the Privacy tab's "Uploads stored at"), and all three path rows
  gained Open folder.
- i18n stragglers routed through t(): storage load/open/clear fallbacks,
  the backend-status badge, and the frontend log buffer label.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:55:08 +05:30
2f3e40db24 fix(settings): dictation & pronunciation panels — error states, language-scoped preview, i18n, a11y, dictionary import/export (#1060)
Settings → Dictation / Pronunciation / remote-ASR panel fixes:

- RefinementPanel no longer vanishes when the initial GET fails (backend
  down/restarting): the section shell always renders, with the error, a
  Retry button, and a loading line — matching its sibling panels.
- RefinementPanel offers the "Open LLM Providers" deep-link as soon as
  llm_ready is false, not only after the first refinement failure.
- Pronunciation test preview gains a preview-language selector and sends
  it to POST /pronunciation/test, so language-scoped entries finally show
  up in the preview instead of a misleading "No entries match"; a hint
  explains that Global previews skip language-scoped entries.
- Preview requests are debounced and sequence-guarded (a slow stale
  response can never overwrite a newer one), failures surface as a
  "preview unavailable" note instead of silently blanking, and the
  preview re-runs after add/toggle/delete/import so it never goes stale.
- Dictionary backup & restore: Export JSON / Import JSON buttons wired to
  the existing GET /pronunciation/export and POST /pronunciation/import
  endpoints (import prompts replace-vs-merge when entries exist).
- a11y: each entry's enable switch is named after its term ("Enable
  GIF"), all add-form/test inputs and selects carry aria-labels, and the
  cramped language field gets a short placeholder with the long
  explanation moved to the row hint.
- RefinementPanel + AecPanel converted to i18n (`dictation.*` keys; the
  refine-failure helper now returns a key instead of hardcoded English),
  per the all-UI-strings-through-i18n convention; "experimental" is now
  sentence-cased via its key.
- Copy: empty state says "Add one below" (the form is below the list, in
  every locale) and the test row is titled "Test a sentence" instead of
  its ellipsized placeholder.
- AsrOpenAICompatPanel: Save is disabled until a field actually differs
  from the server values and shows a "Saved" confirmation after saving —
  URL/model-only edits are no longer silently ambiguous.
- Enter submits the pronunciation add form; Add is disabled while the
  term is blank.

New pronunciation locale keys are translated in all 21 locales (keeping
that namespace fully covered); the new `dictation.*` namespace is en-only
with fallback, matching the `models.asrOpenAICompat*` precedent. No
backend changes — /pronunciation/test already accepted `language`.

Tests: RefinementPanel + AsrOpenAICompatPanel component tests added,
PronunciationPanel tests extended (language-scoped preview, stale-response
guard, preview error, re-run after add, Enter-to-add, per-entry switch
names, export/import round-trip), refine-note test updated for i18n keys.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:42:19 +05:30
6978f90f16 fix(settings): correct About links, wire RTL, and close core Settings UX gaps (#1059)
- About linked the wrong project: "OmniVoice on GitHub" and "Model card"
  opened k2-fsa/OmniVoice. The GitHub button now derives from a single
  REPO_URL constant in utils/bugReport.js (issue/search URLs derive from it
  too), and the Model card button is gone — a multi-engine app has no single
  model card. A test pins the button to the constant so links can't drift.
- Picking Arabic now actually flips the UI to RTL: the languageChanged
  handler sets document dir + lang from i18n.dir(), covering any future RTL
  locale as well.
- A Settings search that matches nothing now shows "No settings match" with
  a Clear action instead of a silently blank sidebar (and an option-less
  nav <select> in the narrow layout).
- Hotkey recording no longer swallows modifier-less presses in silence — it
  shows inline "add a modifier" feedback, the Record button becomes a Cancel
  toggle while listening, window blur cancels the global key listener, and
  the row copy states the modifier requirement.
- About no longer dead-ends on fixable problems: "HF token set: no" and
  failing self-checks deep-link into the owning Settings category via
  openSettingsTab.
- Sidebar search now also matches translated setting-row titles
  (keywordKeys), so localized users can find categories by localized names;
  English keywords keep working everywhere.
- Network gets its missing restart flag (the FFmpeg-path row is a
  restart-bound env write); a lockstep test keeps RestartBadge usage and
  category flags in sync.
- The theme-dot and font-tile radiogroups implement the real WAI-ARIA radio
  pattern: roving tabindex plus arrow-key movement with focus following
  selection.
- Copy cleanup: sentence-case "UI scale" / "Commercial license"; the review
  segmented control moves off the orphaned engines.review_* keys to
  settings.review_mode_on/off (renamed across all 21 locales) with plainer
  English labels.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 02:59:46 +05:30
17ae952810 feat(settings): Models & Engines pages — engine identity marks, capability badges, upgrade hints, filter, residency (#1058)
The engine list gains a scannable identity mark per engine (EngineMark),
capability badges (cloning, device routing with reasons, sidecar isolation),
and surfaces available-but-has-advice hints that list_backends previously
dropped (new additive hint field; the ready-with-advice convention). The
model store gains a filter, disk context near downloads, in-memory residency
indicators with safe unload, copyable setup snippets, and actionable
empty/error states. Registry additions are additive only (hint,
supports_cloning with the property-descriptor guard).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 02:21:21 +05:30
142 changed files with 9218 additions and 1259 deletions
+22
View File
@@ -6,6 +6,28 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [0.3.17] — 2026-07-11
The polish release. The dubbing workspace can no longer trap you — an interrupted dub session used to relaunch into an eternal spinner that even reinstalling couldn't clear (thank you @nanai97 for the screenshot that cracked it). A 58-finding audit of every Settings panel got fixed end to end, **FFmpeg and yt-dlp stopped being your problem** (the app provisions its own, with a new Audio tools panel when you want control), the Engines and Models pages went compact and tabbed, the launcher stopped trusting half-dead backends, and the app finally opens at 100% scale.
### Added
- **FFmpeg, FFprobe, and yt-dlp stopped being your problem.** The setup wizard no longer lists them as system requirements with "brew install" homework — the app provisions them itself: shipped installs already bundle them, and when nothing is found the backend downloads its own checksum-pinned static build in the background, showing a single actionable card only if that fails. A new **Settings → Audio tools** panel gives back the control: per-tool version and origin (App package / Bundled / System / Custom), update / use-system / choose-file / restore-bundled — and **one-click yt-dlp updates** that survive app upgrades, because video-site support changes faster than releases. Install docs updated to match. (#1071)
- **The Engines and Models pages got compact and tabbed.** Engines is now one section with TTS / ASR / LLM tabs; every engine is a strict two-line, fixed-height row with truncated text and aligned status / GPU / isolation / action columns, so the whole engine list fits one screen — details like "Why unavailable?" expand below the row instead of stretching it. Models rows tightened the same way. (#1072)
- **The Engines and Models pages got a full readability-and-features pass.** Every engine row now carries a small identity mark and honest capability badges (voice cloning, device routing with the reason on hover, sidecar isolation), and engines that are ready-but-have-advice finally say so — upgrade hints used to be dropped before reaching the UI. The model store gains a filter, disk-space context next to downloads, "in memory — safe to unload" indicators, copyable setup snippets for opt-in engines, and empty states that tell you what to do next. (#1058)
### Fixed
- **The app no longer attaches to a "zombie" backend that looks alive but fails everything.** If a backend process survived while its install was replaced or deleted underneath it, it kept answering health checks from memory — so the next launch attached to it and every real request failed with a confusing access-control error. The launcher now runs a deeper probe (an endpoint that actually touches the database) before attaching, and replaces any backend that fails it. The local dev/test scripts also now terminate running instances before wiping data, which is how this state was produced. (#1077)
- **The app opens at 100% scale by default.** New installs rendered everything at 130% zoom, which read as oversized on typical displays. Fresh sessions now start at native size; if you already picked a scale in Settings → Appearance, your choice is kept. (#1074)
- **The app no longer relaunches into a dead "generating" dub session — the blank-pane-and-spinner trap.** The saved dub session was restoring its in-flight state verbatim: quit (or crash) while a dub was generating and every subsequent launch waited forever for work that died with the process — and reinstalling couldn't clear it. Interrupted sessions now reopen on the segment editor with all your work intact (or the upload screen if nothing was transcribed yet). Thanks to @nanai97 for the screenshot that told the whole story. (#1067)
- **A 58-finding audit of every Settings panel, fixed end to end.** Highlights: the About page linked to the wrong project's GitHub; Arabic rendered left-to-right (RTL wiring was missing); a saved proxy could never be cleared after a reload; the HF-mirror and refinement panels vanished entirely when the backend was down; "Test now" on the HF token served five-minute-old cached results; factory reset only cleared part of what it promised; pronunciation previews ignored language-scoped entries; the hotkey recorder swallowed invalid presses in silence; Settings search could strand you with an empty sidebar — plus first component tests for previously untested panels, full i18n for five all-English panels, accessible names across inputs, confirmed destructive actions, deep links instead of dead-end advice, temp-file reclaim, and log-sharing workflows. (#1059, #1060, #1061, #1063, #1064)
## [0.3.16] — 2026-07-11
The quality release. Three long-standing frictions got structural fixes: **regenerating no longer destroys good takes** (a takes rail with starring and restore), **audiobooks stop redoing finished work** (per-sentence caching — edit one line, re-render one line; crashes resume where they stopped), and **dub translations stay consistent and fit their timeline** (auto-glossary + a naturalness pass, plus fit prediction before any GPU time is spent). Under the hood, every text path now speaks numbers, times, and abbreviations correctly, the VoxCPM2 engine gained upstream-alignment guards, and a Windows first-run breaker — model downloads completing but the cache ending up with broken file links — now self-heals automatically. Thank you @dmnobunaga for the razor-sharp diagnosis on that last one.
+10 -2
View File
@@ -161,13 +161,18 @@ async def search_youtube(
list. Users are responsible for the licensing of whatever they import.
"""
try:
# yt-dlp is an importable module, never a PATH requirement — run it
# via the interpreter (honors the Settings → Audio tools overlay).
from services.media_tools import ytdlp_invocation
ytdlp_argv, ytdlp_env = ytdlp_invocation()
result = await spawn_subprocess(
"yt-dlp",
*ytdlp_argv,
"--dump-json",
"--remote-components", "ejs:github",
f"ytsearch{max_results}:{query}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=ytdlp_env,
)
stdout, stderr = await result.communicate()
@@ -218,8 +223,10 @@ async def download_youtube_clip(
temp_path = str(VOICE_GALLERY_DIR / f"{voice_id}.%(ext)s")
try:
from services.media_tools import ytdlp_invocation
ytdlp_argv, ytdlp_env = ytdlp_invocation()
cmd = [
"yt-dlp",
*ytdlp_argv,
"--remote-components", "ejs:github",
"-f",
"bestaudio",
@@ -239,6 +246,7 @@ async def download_youtube_clip(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=ytdlp_env,
)
stdout, stderr = await result.communicate()
+85
View File
@@ -0,0 +1,85 @@
"""Media-tools endpoints — the backend for Settings → Audio tools and the
wizard's invisible media-engine self-heal.
Every route is loopback-gated: ``custom-path`` / ``use-system`` point the app
at an arbitrary executable (an RCE primitive if remote-reachable), and the
rest mutate local state. Same contract as ``/system/set-env``.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.dependencies import require_loopback
logger = logging.getLogger("omnivoice.api")
router = APIRouter(dependencies=[Depends(require_loopback)])
class CustomPathRequest(BaseModel):
path: str
def _svc():
# Late import so a service-level failure surfaces as a 500 with detail,
# not an app-boot failure.
from services import media_tools
return media_tools
@router.get("/media-tools/status")
def media_tools_status():
"""Per-tool {ok, path, version, origin} + background-op states."""
return _svc().status()
@router.post("/media-tools/acquire")
def media_tools_acquire():
"""(Re-)fetch the pinned, checksummed static ffmpeg/ffprobe build in the
background. Idempotent; poll /media-tools/status for progress."""
return _svc().acquire_bundled()
# Literal ytdlp routes MUST register before the parametrized {tool} routes —
# FastAPI matches in declaration order, and `/media-tools/{tool}/restore`
# would otherwise swallow `/media-tools/ytdlp/restore` into a 400.
@router.post("/media-tools/ytdlp/update")
def media_tools_ytdlp_update():
"""Fetch the newest yt-dlp wheel (sha256-verified against PyPI metadata)
into the update-surviving overlay. Applies on next backend start."""
return _svc().update_ytdlp()
@router.post("/media-tools/ytdlp/restore")
def media_tools_ytdlp_restore():
"""Drop the overlay — the app-tested, locked yt-dlp takes over on next
start. Always safe (the locked install is never modified)."""
return _svc().restore_ytdlp()
@router.post("/media-tools/{tool}/custom-path")
def media_tools_custom_path(tool: str, body: CustomPathRequest):
try:
return _svc().set_custom_path(tool, body.path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/media-tools/{tool}/use-system")
def media_tools_use_system(tool: str):
try:
return _svc().use_system(tool)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except LookupError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/media-tools/{tool}/restore")
def media_tools_restore(tool: str):
try:
return _svc().restore_bundled(tool)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+31 -2
View File
@@ -77,8 +77,17 @@ def clear_hf_token(also_clear_hf_cli: bool = Query(False)):
@router.get("/hf-token/state")
def get_hf_token_state():
"""3-source HF token cascade state for the Settings UI."""
def get_hf_token_state(fresh: bool = Query(False)):
"""3-source HF token cascade state for the Settings UI.
``fresh=1`` drops the resolver's whoami validation cache first so the
response re-runs whoami for every source this is what the panel's
"Test now" button sends. Plain GETs (panel mounts) keep the 300s cache
so repeat Settings visits don't hammer the HF API.
"""
from services import token_resolver
if fresh:
token_resolver.invalidate_cache()
return _state_response()
@@ -725,6 +734,26 @@ async def get_storage_report(refresh: bool = Query(False)):
raise HTTPException(status_code=500, detail="Failed to compute storage report")
@router.post("/storage/temp/clear")
async def clear_temp_files():
"""Delete OmniVoice-owned temp files (Settings → Storage → Temporary files).
Removes only the ``omnivoice*`` entries in the OS temp dir the exact
population the storage report's "temp" category counts — and invalidates
the cached report so the next scan reflects the reclaimed space. Partial
failures (files held open by a running job) are returned per entry.
"""
from services import storage_report
try:
result = await asyncio.to_thread(storage_report.clear_temp)
storage_report.clear_cache()
return result
except Exception:
logger.exception("clear temp files failed")
raise HTTPException(status_code=500, detail="Failed to clear temporary files")
# ── HF mirror endpoint (parity program Wave 4.3 / §R4 c) ──────────────────
# Restricted-network users (e.g. behind the Great Firewall) need to point
# huggingface_hub at a mirror. HF reads HF_ENDPOINT at import time, so a
+16 -54
View File
@@ -3,7 +3,9 @@
Extracted from the monolithic ``setup.py``.
- ``GET /setup/status`` missing-model gate for boot screen
- ``GET /setup/preflight`` system health check (OS, RAM, GPU, ffmpeg)
- ``GET /setup/preflight`` system health check (OS, RAM, disk, GPU, network
genuine user facts only; the media engine (ffmpeg/ffprobe/yt-dlp) is an
internal concern that self-heals via ``services.media_tools``)
- ``POST /setup/warmup`` background model pre-load
"""
from __future__ import annotations
@@ -12,7 +14,6 @@ import asyncio
import logging
import os
import platform as _platform
import shutil as _shutil
import sys
from fastapi import APIRouter
@@ -280,59 +281,19 @@ def preflight():
f"Fix write permissions on {cache} or point HF_HOME elsewhere.",
})
# ── FFmpeg
ffmpeg_path = None
# ── Media engine (ffmpeg/ffprobe/yt-dlp) — deliberately NOT a check row.
# These are internal dependencies the app provisions for itself, not user
# facts: when the resolution chain has no tier at all, preflight kicks the
# bundled acquisition in the background and the wizard shows a quiet
# progress line (a failure card only if that fails — with Retry / use a
# system copy). yt-dlp is an importable locked module and never appears.
# Power users manage all three in Settings → Audio tools.
media_tools = None
try:
from services.ffmpeg_utils import find_ffmpeg
ffmpeg_path = find_ffmpeg()
except Exception as e:
checks.append({
"id": "ffmpeg", "label": "FFmpeg", "status": "fail",
"detail": str(e)[:200],
"fix": "Install ffmpeg via your package manager "
"(brew install ffmpeg / apt install ffmpeg / choco install ffmpeg).",
})
else:
checks.append({
"id": "ffmpeg", "label": "FFmpeg", "status": "pass",
"detail": ffmpeg_path, "fix": None,
})
# ── FFprobe
ffprobe_path = None
try:
from services.ffmpeg_utils import find_ffprobe
ffprobe_path = find_ffprobe()
except Exception:
pass
if ffprobe_path:
checks.append({
"id": "ffprobe", "label": "FFprobe", "status": "pass",
"detail": ffprobe_path, "fix": None,
})
else:
checks.append({
"id": "ffprobe", "label": "FFprobe", "status": "warn",
"detail": "Not bundled alongside ffmpeg.",
"fix": "File-probe endpoint (/tools/probe) will 501. "
"Install system ffmpeg (includes ffprobe) to enable it.",
})
# ── yt-dlp
yt_dlp_path = _shutil.which("yt-dlp")
if yt_dlp_path:
rc_ytv, yt_ver = _run_cmd([yt_dlp_path, "--version"], timeout=3.0)
yt_version = yt_ver.strip() if rc_ytv == 0 else "unknown"
checks.append({
"id": "yt-dlp", "label": "yt-dlp", "status": "pass",
"detail": f"{yt_dlp_path} (v{yt_version})", "fix": None,
})
else:
checks.append({
"id": "yt-dlp", "label": "yt-dlp", "status": "warn",
"detail": "Not found in system PATH.",
"fix": "YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
})
from services.media_tools import summary as _media_summary
media_tools = _media_summary(auto_acquire=True)
except Exception as exc: # never break preflight on the media engine
logger.warning("preflight media_tools summary failed: %s", exc)
# ── GPU
gpu = _detect_gpu()
@@ -492,6 +453,7 @@ def preflight():
"disk_free_gb": round(free, 1),
},
"gpu_routing": gpu_routing,
"media_tools": media_tools,
}
+8 -6
View File
@@ -629,15 +629,17 @@ def system_notifications():
notes.append({
"id": "ffmpeg-missing",
"level": "error",
"title": "ffmpeg not found",
"title": "Media engine unavailable",
"message": (
"Video processing, audio conversion, and dubbing require ffmpeg. "
"Install it with: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)."
"Video processing, audio conversion, and dubbing need the "
"media engine (ffmpeg), which the app normally provisions "
"itself. Open Settings > Audio tools and press Restore "
"bundled to re-download it, or point it at a system copy."
),
"action": {
"label": "Install guide",
"type": "link",
"target": "https://ffmpeg.org/download.html",
"label": "Open Audio tools",
"type": "settings-tab",
"target": "audio-tools",
},
})
+5
View File
@@ -164,6 +164,11 @@ class PreflightResponse(BaseModel):
# Explicit field (PreflightResponse has no extra="allow") so the verdict
# survives serialization instead of being silently dropped.
gpu_routing: GpuRouting | None = None
# Media-engine verdict (ffmpeg/ffprobe) — NOT a check row: an internal
# dependency the app provisions for itself. Shape: {ready, acquire:
# {state, progress, error}}. The wizard renders a quiet progress line /
# failure card from it instead of "install ffmpeg" system requirements.
media_tools: dict | None = None
class InstallModelRequest(BaseModel):
+24 -8
View File
@@ -88,17 +88,33 @@ def _check_device() -> dict:
def _check_ffmpeg() -> dict:
"""Media engine (ffmpeg + ffprobe) — an internal dependency the app
bundles/acquires itself, so a failure here means the self-heal also has
nothing to work with (and the hint says where the controls live)."""
ffmpeg = ffprobe = None
try:
from services.ffmpeg_utils import find_ffmpeg
path = find_ffmpeg()
from services.ffmpeg_utils import find_ffmpeg, find_ffprobe
ffmpeg = find_ffmpeg()
ffprobe = find_ffprobe()
except Exception:
path = None
if path:
return _check("ffmpeg", "ffmpeg", OK, str(path))
pass
if ffmpeg and ffprobe:
return _check("ffmpeg", "Media engine (ffmpeg)", OK,
f"ffmpeg: {ffmpeg}; ffprobe: {ffprobe}")
if ffmpeg:
return _check(
"ffmpeg", "Media engine (ffmpeg)", WARN,
f"ffmpeg: {ffmpeg}; ffprobe missing",
"Media probing (Smart Fit, file inspection) is degraded. Open "
"Settings > Audio tools and press Restore bundled to fetch the "
"app's own ffprobe, or point it at a system copy there.",
)
return _check(
"ffmpeg", "ffmpeg", FAIL,
"not found on PATH or FFMPEG_PATH",
"Dubbing and audio conversion need ffmpeg: brew install ffmpeg (macOS), apt install ffmpeg (Linux), or set the path in Settings > General.",
"ffmpeg", "Media engine (ffmpeg)", FAIL,
"no runnable ffmpeg in any tier (sidecar, bundled, system, custom)",
"Dubbing and audio conversion are unavailable. The app normally "
"provisions ffmpeg itself — open Settings > Audio tools and press "
"Restore bundled (needs network once), or choose a system copy there.",
)
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
_FALLBACK_VERSION = "0.3.16"
_FALLBACK_VERSION = "0.3.17"
def _fallback_version() -> str:
+14
View File
@@ -195,6 +195,18 @@ try:
except Exception:
pass # prefs.json missing or broken — fine on first run
# ── Activate the yt-dlp user-update overlay (Settings → Audio tools) ──────
# Must run before anything imports yt_dlp so a user-updated version (stored
# under DATA_DIR, surviving app updates and uv drift syncs) wins over the
# locked wheel. Best-effort: a broken overlay must never block startup.
try:
from services.media_tools import activate_ytdlp_overlay
activate_ytdlp_overlay()
except Exception:
# Best-effort by design: a broken/corrupt overlay must never block
# startup — the locked wheel on sys.path is the fallback.
pass
warnings.filterwarnings("ignore", category=UserWarning)
torchaudio.set_audio_backend("soundfile")
@@ -375,6 +387,7 @@ from api.routers import (
longform_jobs,
pronunciation, # Expressive-TTS Spec 01: user pronunciation dictionary
settings as settings_router, # Phase 1 AUTH-03: HF token save/clear/state
media_tools as media_tools_router, # Audio tools: ffmpeg/ffprobe/yt-dlp management
)
from utils import hf_progress
@@ -1045,6 +1058,7 @@ app.include_router(audiobook.router)
app.include_router(longform_jobs.router)
app.include_router(pronunciation.router) # Expressive-TTS Spec 01: pronunciation dictionary
app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints
app.include_router(media_tools_router.router) # Settings → Audio tools + wizard media-engine self-heal
from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402
app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings
+39 -5
View File
@@ -57,9 +57,13 @@ def find_ffmpeg():
"""Locate an ffmpeg binary.
Resolution order:
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled).
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled, or
by the user's Settings → Audio tools override via prefs).
2. ``imageio-ffmpeg`` pip package (ships a static binary per platform).
3. Common system paths / ``PATH``.
3. OmniVoice-acquired static bundle (``services.media_tools``) the
checksummed build the app downloads itself when nothing else
resolves; the only bundled tier that also ships ffprobe.
4. Common system paths / ``PATH``.
Returns the path string, or ``None`` if nothing found.
"""
@@ -78,7 +82,13 @@ def find_ffmpeg():
logger.debug("imageio_ffmpeg binary not usable at %s", candidate)
except Exception as e:
logger.debug("imageio_ffmpeg unavailable: %s", e)
# 3. Well-known system paths + PATH lookup
# 3. OmniVoice-acquired bundled static binary (never downloads here —
# acquisition is media_tools' background job; this only picks up an
# already-installed build).
candidate = _acquired_bundled("ffmpeg")
if candidate:
return candidate
# 4. Well-known system paths + PATH lookup
common = [
"/opt/homebrew/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
@@ -95,6 +105,22 @@ def find_ffmpeg():
return None
def _acquired_bundled(tool: str) -> "str | None":
"""Already-acquired media_tools static binary, validated — or None.
Lazy import: media_tools imports from this module at its top, so this
module must only reach back at call time (no cycle).
"""
try:
from services.media_tools import bundled_tool_path
candidate = bundled_tool_path(tool)
if candidate and _binary_runs(candidate):
return candidate
except Exception as e:
logger.debug("media_tools bundled %s unavailable: %s", tool, e)
return None
def resolve_ffprobe() -> str | None:
"""Resolve an ffprobe binary path.
@@ -103,8 +129,12 @@ def resolve_ffprobe() -> str | None:
injected by Tauri pointing at the bundled sidecar (e.g.
``/usr/lib/omnivoice-studio/bin/ffprobe`` on .deb installs).
2. ``FFPROBE_PATH`` env var legacy alias kept for backward
compatibility with older Tauri shells / dev environments.
3. ``shutil.which("ffprobe")`` system ``PATH`` fallback.
compatibility with older Tauri shells / dev environments; also the
key Settings Audio tools persists a user override under.
3. OmniVoice-acquired static bundle (``services.media_tools``)
imageio-ffmpeg ships no ffprobe, so this is the bundled tier that
closes the source-install gap.
4. ``shutil.which("ffprobe")`` system ``PATH`` fallback.
Returns the resolved path string, or ``None`` if nothing found. Callers
that need a hard failure should use :func:`find_ffprobe` instead.
@@ -121,6 +151,10 @@ def resolve_ffprobe() -> str | None:
if resolved and _binary_runs(resolved):
return resolved
bundled = _acquired_bundled("ffprobe")
if bundled:
return bundled
system_probe = shutil.which("ffprobe")
if system_probe and _binary_runs(system_probe):
return system_probe
+637
View File
@@ -0,0 +1,637 @@
"""Media tools — ffmpeg / ffprobe / yt-dlp as an invisible internal concern.
Most users should never learn what ffmpeg is. This module makes the media
engine self-contained: it reports where each tool comes from, acquires a
bundled static build in the background when no tier of the resolution chain
(``services.ffmpeg_utils``) resolves, and gives power users explicit
control (custom path / system copy / restore bundled) through the
``/media-tools`` router persisted via the same ``env.FFMPEG_PATH`` /
``env.FFPROBE_PATH`` prefs convention the Settings env writer already uses,
so there is exactly one override mechanism.
Bundled-binary source (decision record)
---------------------------------------
The gap: ``imageio-ffmpeg`` (already a locked dep) ships a static *ffmpeg*
inside its platform wheels but **no ffprobe**, so source installs without a
system ffmpeg lose ``/tools/probe``, Smart-Fit duration checks, and VFR
detection. Two options were audited:
(a) the ``static-ffmpeg`` pip package ships BOTH binaries per platform via
lazy download. **Rejected**: it downloads from a *mutable* URL
(``.../ffmpeg_bins/raw/main/...`` the branch tip, not a pinned
release), performs **no checksum validation**, extracts into its own
``site-packages`` directory (read-only / non-existent in the frozen
PyInstaller backend), and drags in ``requests``/``filelock``/``progress``
plus a stdout spinner.
(b) fetch the same upstream static builds ourselves, pinned to an immutable
commit. **Chosen**: we download the platform zip from
``github.com/zackees/ffmpeg_bins`` at a pinned commit SHA (immutable
URL), verify size + SHA-256 against constants recorded from that
commit's git-LFS pointers, extract only ffmpeg/ffprobe into a
user-writable, update-surviving dir under ``DATA_DIR``, and trust a
binary only after the existing ``_binary_runs`` ``-version`` probe.
Stdlib-only (urllib honors HTTP(S)_PROXY), identical behavior on
macOS (arm64 + x86_64), Windows x64, and Linux (x64 + arm64), and zero
new Python dependencies.
yt-dlp updates (decision record)
--------------------------------
yt-dlp is an importable locked dep never a user-installed requirement.
But site support rots faster than app releases, so Settings offers a
user-triggered "Update". A plain in-venv upgrade was audited and rejected:
the app venv is uv-managed (no pip module), and the updater's drift sync
(#1029/#1030, ``uv sync --frozen --inexact``) preserves only packages *not*
in the lockfile yt-dlp IS locked, so an in-venv upgrade would be silently
reverted on the next app update, and the frozen build has no installer at
all. Instead we install the new wheel (pure-python, no required deps
matching the plain ``yt-dlp`` spec pinned in pyproject) into an **overlay
directory** under ``DATA_DIR`` SHA-256-verified against PyPI's own
metadata and prepend it to ``sys.path`` at startup. It survives app
updates and drift syncs, works identically in source and frozen builds, and
"Restore tested version" is simply deleting the overlay: the locked wheel
underneath was never touched.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import zipfile
from core.config import DATA_DIR
from core import prefs
from services.ffmpeg_utils import _binary_runs, _BINARY_OK
logger = logging.getLogger("omnivoice.media_tools")
# ── Pinned bundled build ────────────────────────────────────────────────────
# Immutable commit of github.com/zackees/ffmpeg_bins (the upstream the
# static-ffmpeg pip package also consumes, but pinned + checksummed here).
# SHA-256 values are the git-LFS oids of the v8.0 platform zips at this
# commit, independently verified by downloading and hashing.
_FFBIN_REPO = "zackees/ffmpeg_bins"
_FFBIN_COMMIT = "df95abcb0ce6efff710dda5ef28a2f6f1dc21493" # 2026-01-16
_FFBIN_TREE = "v8.0"
#: platform key → (sha256, size in bytes) of the zip at the pinned commit.
_FFBIN_SHA256 = {
"darwin": ("70fd5b21cb37b6ea97c8b584cf76b3cc6a90179831c9c269811b9716c28605fb", 53079896),
"darwin_arm64": ("b2da44a8169c4d09a97db996250690c3346f72e4795521d23d3dbb1e72421207", 41925556),
"linux": ("ca75b05e887c7a97676632f673031875847be83daa9794298fed9cef8cac14ad", 142008975),
"linux_arm64": ("e03efe471c03b999f10988d5db62ae3bd94837463291b3c7755528b100e97d6f", 131816005),
"win32": ("92662c2241e93fe71b3f3a01e94a0b0dc8cfad726019f96b83bc109ce44c5d0b", 72065209),
}
_PYPI_YTDLP_URL = "https://pypi.org/pypi/yt-dlp/json"
_DOWNLOAD_TIMEOUT_S = 30 # per-read socket timeout; downloads stream in chunks
_CHUNK = 256 * 1024
#: tool → env keys honored by the resolution chain, in precedence order.
_ENV_KEYS = {
"ffmpeg": ("FFMPEG_PATH",),
"ffprobe": ("OMNIVOICE_FFPROBE_PATH", "FFPROBE_PATH"),
}
#: tool → the env key the *user override* is persisted under (prefs `env.<KEY>`).
_PREF_ENV_KEY = {"ffmpeg": "FFMPEG_PATH", "ffprobe": "FFPROBE_PATH"}
TOOLS = ("ffmpeg", "ffprobe")
# ── Background-operation state (poll via status()) ─────────────────────────
_lock = threading.Lock()
_ops: dict[str, dict] = {
"acquire": {"state": "idle", "progress": 0.0, "error": None},
"ytdlp_update": {"state": "idle", "progress": 0.0, "error": None, "version": None},
}
_version_cache: dict[str, str] = {}
def _set_op(op: str, **fields) -> None:
with _lock:
_ops[op].update(fields)
def _op_snapshot() -> dict:
with _lock:
return {k: dict(v) for k, v in _ops.items()}
# ── Platform / paths ────────────────────────────────────────────────────────
def _platform_key() -> str:
import platform as _p
is_arm = _p.machine().lower() in ("arm64", "aarch64")
if sys.platform == "win32":
return "win32"
if sys.platform == "darwin":
return "darwin_arm64" if is_arm else "darwin"
if sys.platform.startswith("linux"):
return "linux_arm64" if is_arm else "linux"
return sys.platform
def media_tools_dir() -> str:
"""User-writable root for acquired binaries + the yt-dlp overlay.
Lives in DATA_DIR so it survives app updates (the app bundle / venv are
replaced wholesale on update; DATA_DIR is user state) and is writable in
frozen installs.
"""
return os.path.join(DATA_DIR, "media_tools")
def bundled_dir() -> str:
# Versioned by the pin so a future pin bump lands in a fresh dir and
# "Update" is a plain re-acquire — no in-place mutation of a live binary.
return os.path.join(media_tools_dir(), f"ffbin-{_FFBIN_COMMIT[:12]}", _platform_key())
def _exe(name: str) -> str:
return f"{name}.exe" if sys.platform == "win32" else name
def bundled_tool_path(tool: str) -> str | None:
"""Path of an already-acquired bundled binary, or None. Never downloads."""
p = os.path.join(bundled_dir(), _exe(tool))
return p if os.path.isfile(p) else None
def _bundle_url() -> str:
# github.com/<repo>/raw/<commit> redirects to the LFS media host and
# serves the real zip (raw.githubusercontent.com would return the
# 133-byte LFS pointer instead).
return f"https://github.com/{_FFBIN_REPO}/raw/{_FFBIN_COMMIT}/{_FFBIN_TREE}/{_platform_key()}.zip"
def _expected_bundle() -> tuple[str, str, int]:
"""(url, sha256, size) for this platform. Raises on unsupported platform."""
key = _platform_key()
if key not in _FFBIN_SHA256:
raise RuntimeError(f"no bundled media-engine build for platform '{key}'")
sha, size = _FFBIN_SHA256[key]
return _bundle_url(), sha, size
# ── Download helper ─────────────────────────────────────────────────────────
def _download(url: str, dest_path: str, expected_sha256: str,
expected_size: int | None, op: str) -> None:
"""Stream *url* to *dest_path*, hashing on the fly; raise on mismatch.
Progress is reported into ``_ops[op]["progress"]``. urllib honors the
HTTP(S)_PROXY env vars, so restricted-network users' proxy settings apply.
"""
import urllib.request
if not url.startswith("https://"):
raise ValueError("media-tools downloads must be https")
req = urllib.request.Request(url, headers={"User-Agent": "OmniVoice-Studio"})
hasher = hashlib.sha256()
done = 0
with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
total = expected_size or int(resp.headers.get("Content-Length") or 0)
with open(dest_path, "wb") as f:
while True:
chunk = resp.read(_CHUNK)
if not chunk:
break
f.write(chunk)
hasher.update(chunk)
done += len(chunk)
if total:
_set_op(op, progress=min(done / total, 1.0))
digest = hasher.hexdigest()
if expected_size is not None and done != expected_size:
raise RuntimeError(f"download size mismatch: got {done}, expected {expected_size}")
if digest != expected_sha256:
raise RuntimeError("download checksum mismatch — refusing to install")
# ── Bundled acquisition ─────────────────────────────────────────────────────
def acquire_bundled(wait: bool = False) -> dict:
"""Fetch + verify + install the pinned static ffmpeg/ffprobe build.
Idempotent: a no-op when the binaries are already present and runnable,
or when an acquisition is already running. Runs in a daemon thread so it
never blocks the caller (``wait=True`` is for tests/CLI use).
Returns the op-state snapshot.
"""
with _lock:
if _ops["acquire"]["state"] == "running":
return dict(_ops["acquire"])
_ops["acquire"].update(state="running", progress=0.0, error=None)
if all(bundled_tool_path(t) and _binary_runs(bundled_tool_path(t)) for t in TOOLS):
_set_op("acquire", state="done", progress=1.0)
return _op_snapshot()["acquire"]
def _worker():
try:
_do_acquire()
_set_op("acquire", state="done", progress=1.0, error=None)
logger.info("media-tools: bundled ffmpeg/ffprobe installed at %s", bundled_dir())
except Exception as e:
logger.warning("media-tools: bundled acquisition failed: %s", e)
_set_op("acquire", state="error", error=str(e)[:300])
if wait:
_worker()
else:
threading.Thread(target=_worker, name="media-tools-acquire", daemon=True).start()
return _op_snapshot()["acquire"]
def _do_acquire() -> None:
url, sha, size = _expected_bundle()
target = bundled_dir()
os.makedirs(os.path.dirname(target), exist_ok=True)
with tempfile.TemporaryDirectory(dir=os.path.dirname(target)) as tmp:
zip_path = os.path.join(tmp, "bundle.zip")
_download(url, zip_path, sha, size, op="acquire")
# Extract only the two binaries, flattened by basename — layout-agnostic
# and immune to zip-slip (we never honor archive paths).
wanted = {_exe(t): t for t in TOOLS}
staged = os.path.join(tmp, "staged")
os.makedirs(staged, exist_ok=True)
found: dict[str, str] = {}
with zipfile.ZipFile(zip_path) as zf:
for member in zf.infolist():
base = os.path.basename(member.filename)
if base in wanted and not member.is_dir():
out = os.path.join(staged, base)
with zf.open(member) as src, open(out, "wb") as dst:
shutil.copyfileobj(src, dst)
# Owner-only rwx — the backend process is the sole consumer
# of these binaries (least privilege; py/overly-permissive-file).
os.chmod(out, 0o700)
found[base] = out
missing = set(wanted) - set(found)
if missing:
raise RuntimeError(f"bundle is missing {sorted(missing)}")
# Probe BEFORE trusting — a corrupt / wrong-arch binary must never
# be installed (same contract as ffmpeg_utils._binary_runs at
# resolution time, applied at install time).
for base, path in found.items():
_BINARY_OK.pop(path, None)
if not _binary_runs(path):
raise RuntimeError(f"downloaded {base} failed its -version probe")
# Finalize: swap the staged dir into place.
if os.path.isdir(target):
shutil.rmtree(target, ignore_errors=True)
os.replace(staged, target)
# Resolution caches may hold negative verdicts for the old paths.
for t in TOOLS:
p = os.path.join(target, _exe(t))
_BINARY_OK.pop(p, None)
_version_cache.pop(p, None)
# ── Status / origin classification ─────────────────────────────────────────
def _tool_version(path: str) -> str | None:
cached = _version_cache.get(path)
if cached:
return cached
try:
out = subprocess.run(
[path, "-version"], capture_output=True, text=True, timeout=10, check=False,
).stdout
m = re.match(r"^(?:ffmpeg|ffprobe) version (\S+)", out or "")
if m:
_version_cache[path] = m.group(1)
return m.group(1)
except Exception as e:
logger.debug("version probe failed for %s: %s", os.path.basename(path), e)
return None
def _imageio_pkg_dir() -> str | None:
try:
import imageio_ffmpeg
return os.path.dirname(os.path.abspath(imageio_ffmpeg.__file__))
except Exception:
return None
def _classify_origin(tool: str, path: str) -> str:
"""sidecar | bundled | system | custom — where the resolved binary lives."""
rp = os.path.realpath(path)
for root in filter(None, (media_tools_dir(), _imageio_pkg_dir())):
if rp.startswith(os.path.realpath(root) + os.sep):
return "bundled"
for key in _ENV_KEYS[tool]:
v = os.environ.get(key)
if not v:
continue
if v == path or os.path.realpath(v) == rp or shutil.which(v) == path:
# The same env var serves two masters: the Tauri sidecar injects
# it at spawn; a user override persists it via prefs `env.<KEY>`.
return "custom" if prefs.get(f"env.{key}") else "sidecar"
return "system"
def _resolve(tool: str) -> str | None:
from services import ffmpeg_utils
if tool == "ffmpeg":
return ffmpeg_utils.find_ffmpeg()
return ffmpeg_utils.find_ffprobe()
def _ytdlp_status() -> dict:
"""yt-dlp is a python module, not a binary — status reads its version
without paying the full package import."""
info: dict = {"tool": "yt-dlp", "ok": False, "path": None, "version": None,
"origin": "bundled", "overlay_version": None,
"baseline_version": prefs.get("media_tools.ytdlp_baseline")}
try:
import importlib.util
spec = importlib.util.find_spec("yt_dlp")
origin = getattr(spec, "origin", None)
if origin:
pkg_dir = os.path.dirname(origin)
info["path"] = pkg_dir
info["ok"] = True
info["version"] = _read_ytdlp_version(pkg_dir)
if os.path.realpath(pkg_dir).startswith(
os.path.realpath(_ytdlp_overlay_dir()) + os.sep):
info["origin"] = "custom"
except Exception as e:
logger.debug("yt_dlp spec lookup failed: %s", e)
ov = _read_ytdlp_version(os.path.join(_ytdlp_overlay_dir(), "yt_dlp"))
info["overlay_version"] = ov
return info
def _read_ytdlp_version(pkg_dir: str) -> str | None:
try:
with open(os.path.join(pkg_dir, "version.py"), encoding="utf-8") as f:
m = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", f.read())
return m.group(1) if m else None
except OSError:
return None
def status() -> dict:
"""Full media-tools report: per-tool {ok, path, version, origin} + op states."""
tools = {}
for tool in TOOLS:
path = _resolve(tool)
tools[tool] = {
"tool": tool,
"ok": bool(path),
"path": path,
"version": _tool_version(path) if path else None,
"origin": _classify_origin(tool, path) if path else None,
}
tools["ytdlp"] = _ytdlp_status()
ops = _op_snapshot()
return {
"ready": tools["ffmpeg"]["ok"] and tools["ffprobe"]["ok"],
"tools": tools,
"ops": ops,
"platform_key": _platform_key(),
}
def summary(auto_acquire: bool = False) -> dict:
"""Small preflight-embeddable verdict. With ``auto_acquire``, kicks off
the bundled download in the background when nothing resolves (first-run
self-heal) but never re-fires after a failed attempt (the wizard's
failure card owns the Retry)."""
st = status()
op = st["ops"]["acquire"]
if auto_acquire and not st["ready"] and op["state"] == "idle":
op = acquire_bundled()
return {
"ready": st["ready"],
"acquire": {"state": op["state"], "progress": op["progress"], "error": op["error"]},
}
# ── User overrides (persisted via the existing env-prefs convention) ───────
def _validate_binary_path(path: str) -> None:
# Same defense-in-depth as /system/set-env: no control chars, must be an
# existing file, and must actually run before we trust it.
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in path):
raise ValueError("Invalid path: control characters are not allowed")
if not os.path.isfile(path):
raise ValueError(f"File not found: {path}")
_BINARY_OK.pop(path, None)
if not _binary_runs(path):
raise ValueError(
"That file exists but does not run as a media tool "
"(its `-version` probe failed) — wrong architecture or not executable."
)
def set_custom_path(tool: str, path: str) -> dict:
"""Pin *tool* to an explicit binary. Persists via prefs `env.<KEY>` —
the exact mechanism /system/set-env uses, so there is one override store."""
if tool not in TOOLS:
raise ValueError(f"unknown tool '{tool}'")
path = path.strip()
_validate_binary_path(path)
key = _PREF_ENV_KEY[tool]
os.environ[key] = path
prefs.set_(f"env.{key}", path)
_version_cache.pop(path, None)
logger.info("media-tools: %s pinned to user path (origin=%s)",
tool, _classify_origin(tool, path))
return status()["tools"][tool]
def use_system(tool: str) -> dict:
"""Auto-detect a system-installed copy and pin it."""
if tool not in TOOLS:
raise ValueError(f"unknown tool '{tool}'")
candidate = _detect_system(tool)
if not candidate:
raise LookupError(
f"No system {tool} found on PATH or in the usual install locations."
)
return set_custom_path(tool, candidate)
def _detect_system(tool: str) -> str | None:
roots = [r for r in (media_tools_dir(), _imageio_pkg_dir()) if r]
def _is_bundled(p: str) -> bool:
rp = os.path.realpath(p)
return any(rp.startswith(os.path.realpath(r) + os.sep) for r in roots)
candidates = [
f"/opt/homebrew/bin/{tool}",
f"/usr/local/bin/{tool}",
f"/usr/bin/{tool}",
f"C:\\ffmpeg\\bin\\{tool}.exe",
f"C:\\Program Files\\ffmpeg\\bin\\{tool}.exe",
tool,
]
for c in candidates:
resolved = shutil.which(c)
if resolved and not _is_bundled(resolved) and _binary_runs(resolved):
return resolved
return None
def restore_bundled(tool: str) -> dict:
"""Clear the user override so the chain resolves sidecar → bundled →
system again; kick acquisition if no bundled build is present. Always safe."""
if tool not in TOOLS:
raise ValueError(f"unknown tool '{tool}'")
for key in _ENV_KEYS[tool]:
if prefs.get(f"env.{key}"):
prefs.delete(f"env.{key}")
os.environ.pop(key, None)
_version_cache.clear()
if not (bundled_tool_path(tool) and _binary_runs(bundled_tool_path(tool))):
# No local bundled build to fall back to (imageio may still cover
# ffmpeg) — fetch ours in the background so the revert lands somewhere.
if not _resolve(tool):
acquire_bundled()
return status()["tools"][tool]
# ── yt-dlp overlay ──────────────────────────────────────────────────────────
def _ytdlp_overlay_dir() -> str:
return os.path.join(media_tools_dir(), "ytdlp_overlay")
def activate_ytdlp_overlay() -> bool:
"""Prepend the user-updated yt-dlp overlay to sys.path. Called once at
backend startup, before anything imports yt_dlp."""
overlay = _ytdlp_overlay_dir()
if os.path.isdir(os.path.join(overlay, "yt_dlp")) and overlay not in sys.path:
sys.path.insert(0, overlay)
logger.info("media-tools: yt-dlp overlay active (%s)",
_read_ytdlp_version(os.path.join(overlay, "yt_dlp")) or "?")
return True
return False
def _fetch_pypi_ytdlp() -> tuple[str, str, str]:
"""(version, wheel_url, sha256) of the latest yt-dlp wheel on PyPI."""
import json
import urllib.request
req = urllib.request.Request(_PYPI_YTDLP_URL, headers={"User-Agent": "OmniVoice-Studio"})
with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
meta = json.load(resp)
version = meta["info"]["version"]
for artifact in meta.get("urls", []):
if artifact.get("packagetype") == "bdist_wheel" and \
artifact["filename"].endswith("py3-none-any.whl"):
return version, artifact["url"], artifact["digests"]["sha256"]
raise RuntimeError(f"no universal wheel found for yt-dlp {version}")
def update_ytdlp(wait: bool = False) -> dict:
"""Install the newest yt-dlp into the overlay dir (background thread).
The wheel is verified against PyPI's own sha256 digest before a single
byte lands in the overlay; the swap is atomic (staged dir + os.replace).
Takes effect on the next backend start (the running process already
imported the old module) the UI shows the restart affordance.
"""
with _lock:
if _ops["ytdlp_update"]["state"] == "running":
return dict(_ops["ytdlp_update"])
_ops["ytdlp_update"].update(state="running", progress=0.0, error=None, version=None)
def _worker():
try:
version = _do_update_ytdlp()
_set_op("ytdlp_update", state="done", progress=1.0, version=version)
logger.info("media-tools: yt-dlp overlay updated to %s", version)
except Exception as e:
logger.warning("media-tools: yt-dlp update failed: %s", e)
_set_op("ytdlp_update", state="error", error=str(e)[:300])
if wait:
_worker()
else:
threading.Thread(target=_worker, name="media-tools-ytdlp", daemon=True).start()
return _op_snapshot()["ytdlp_update"]
def _do_update_ytdlp() -> str:
version, url, sha = _fetch_pypi_ytdlp()
# Record the locked ("tested") version once, before the first overlay
# ever activates — that's what "Restore tested version" reverts to.
if prefs.get("media_tools.ytdlp_baseline") is None:
current = _ytdlp_status()
if current["origin"] == "bundled" and current["version"]:
prefs.set_("media_tools.ytdlp_baseline", current["version"])
overlay = _ytdlp_overlay_dir()
os.makedirs(media_tools_dir(), exist_ok=True)
with tempfile.TemporaryDirectory(dir=media_tools_dir()) as tmp:
whl = os.path.join(tmp, "yt_dlp.whl")
_download(url, whl, sha, None, op="ytdlp_update")
staged = os.path.join(tmp, "staged")
with zipfile.ZipFile(whl) as zf:
for member in zf.infolist():
name = member.filename
# Only the package itself; wheels carry no absolute paths but
# guard against traversal anyway.
if not name.startswith("yt_dlp/") or ".." in name:
continue
zf.extract(member, staged)
got = _read_ytdlp_version(os.path.join(staged, "yt_dlp"))
if not got:
raise RuntimeError("downloaded wheel has no readable yt_dlp version")
if os.path.isdir(overlay):
shutil.rmtree(overlay, ignore_errors=True)
os.replace(staged, overlay)
return version
def ytdlp_invocation() -> "tuple[list[str], dict[str, str] | None]":
"""(argv prefix, env-or-None) for running the yt-dlp CLI.
Prefers ``[sys.executable, -m, yt_dlp]`` so the CLI always matches the
module the app ships (or the user's overlay — propagated via PYTHONPATH),
with no PATH requirement: yt-dlp is never something the user installs.
Frozen builds can't re-invoke an interpreter, so they keep the historical
PATH lookup as a last resort.
"""
if not getattr(sys, "frozen", False):
try:
import importlib.util
if importlib.util.find_spec("yt_dlp") is not None:
env = None
overlay = _ytdlp_overlay_dir()
if os.path.isdir(os.path.join(overlay, "yt_dlp")):
env = dict(os.environ)
env["PYTHONPATH"] = overlay + os.pathsep + env.get("PYTHONPATH", "")
return [sys.executable, "-m", "yt_dlp"], env
except Exception as e:
logger.debug("yt_dlp module CLI unavailable: %s", e)
exe = shutil.which("yt-dlp")
return ([exe] if exe else ["yt-dlp"]), None
def restore_ytdlp() -> dict:
"""Delete the overlay — the locked, tested yt-dlp underneath takes over on
next start. Always safe: the locked install was never modified."""
overlay = _ytdlp_overlay_dir()
if os.path.isdir(overlay):
shutil.rmtree(overlay, ignore_errors=True)
_set_op("ytdlp_update", state="idle", progress=0.0, error=None, version=None)
return _ytdlp_status()
+36
View File
@@ -469,3 +469,39 @@ def clear_cache() -> None:
"""Testing hook — drop the in-process cache."""
with _cache_lock:
_cache.update(key=None, ts=0.0, report=None)
def clear_temp(temp_root: str | None = None) -> dict:
"""Delete the app-owned ``omnivoice*`` entries in the OS temp dir.
Removes exactly the population ``build_report`` counts as the "temp"
category direct children of ``temp_root`` whose basename starts with
``omnivoice`` so nothing outside OmniVoice's own working files can ever
be swept up. Symlinked entries are unlinked, never followed, so a stray
``omnivoice*`` link cannot make this delete its target's contents.
Returns ``{"removed": [basenames], "freed_bytes": int, "errors":
[{"path", "error"}]}`` partial failures (e.g. a file held open by a
running job on Windows) are reported per entry instead of aborting.
"""
temp_root = temp_root if temp_root is not None else tempfile.gettempdir()
removed: list[str] = []
errors: list[dict] = []
freed = 0
deadline = time.monotonic() + CATEGORY_TIMEOUT_SECONDS
for p in sorted(glob.glob(os.path.join(glob.escape(temp_root), "omnivoice*"))):
try:
if os.path.islink(p):
size = 0
os.unlink(p)
elif os.path.isfile(p):
size = os.path.getsize(p)
os.unlink(p)
else:
size, _complete, _err = _dir_size(p, deadline)
shutil.rmtree(p)
removed.append(os.path.basename(p))
freed += size
except OSError as e:
errors.append({"path": p, "error": str(e)})
return {"removed": removed, "freed_bytes": freed, "errors": errors}
+3 -2
View File
@@ -30,7 +30,7 @@ logger = logging.getLogger("omnivoice.token_resolver")
Source = Literal["app", "env", "hf-cli"]
_PRIORITY: tuple[Source, ...] = ("app", "env", "hf-cli")
_CACHE_TTL_SECONDS = 300.0 # See Open Question #4 — UI "Test now" calls invalidate.
_CACHE_TTL_SECONDS = 300.0 # UI "Test now" busts it via GET /hf-token/state?fresh=1.
@dataclass(frozen=True)
@@ -57,7 +57,8 @@ _CACHE_LOCK = threading.Lock()
def invalidate_cache() -> None:
"""Drop the whoami validation cache. Called by the Settings UI "Test now"
button (Plan 01-02) and by save/clear API endpoints (Task 3)."""
button (GET /api/settings/hf-token/state?fresh=1), by save/clear API
endpoints, and by on_401()."""
with _CACHE_LOCK:
_VALIDATION_CACHE.clear()
+36
View File
@@ -55,6 +55,27 @@ def _mask_hf_tokens(value):
return _HF_TOKEN_MASK_RE.sub(_HF_TOKEN_MASK, value)
def _available_hint(msg) -> Optional[str]:
"""Advisory text carried by an *available* engine's ``is_available()``
message, or None when the message is a plain readiness echo.
Convention (established by VoxCPM2's version-floor hint): an engine
that is available but wants the user to know something returns
``(True, "ready — <advice>")``. This extracts ``<advice>`` so
:func:`list_backends` can surface it previously the whole message
was dropped for available rows (``reason`` is None when ok), so
upgrade hints never reached the UI. Plain "ready" / "ready (…)"
messages yield None. Output is token-masked like ``reason``.
"""
if not isinstance(msg, str):
return None
head, sep, advice = msg.partition("")
advice = advice.strip()
if not sep or not advice or not head.strip().lower().startswith("ready"):
return None
return _mask_hf_tokens(advice)
# ── HF Hub closed-client recovery (#880) ────────────────────────────────────
#
# huggingface_hub ≥1.x shares ONE global httpx client across every download.
@@ -1687,11 +1708,16 @@ def list_backends() -> list[dict]:
"display_name": str,
"available": bool,
"reason": Optional[str], # message when not available
"hint": Optional[str], # advice when available-but-has-advice
# (is_available "ready — <advice>" convention;
# e.g. VoxCPM2's >=2.0.3 upgrade hint)
"install_hint": Optional[str],
"setup_snippet": Optional[str], # exact `export VAR=...` for path-gated opt-in engines
"last_error": Optional[str], # cached most-recent failure
"isolation_mode": "in-process" | "subprocess",
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, cpu}
"supports_cloning": Optional[bool], # True/False from the class attr; None when
# model-dependent (property, e.g. mlx-audio)
"effective_device": str, # device this engine uses on THIS host
"routing_status": "accelerated" | "cpu_fallback" | "cpu_only" | "unavailable",
"routing_reason": Optional[str], # scrubbed; null when none
@@ -1746,11 +1772,21 @@ def list_backends() -> list[dict]:
else:
isolation = "in-process"
gpu_compat = getattr(cls, "gpu_compat", ("cpu",))
# Cloning capability: same descriptor guard as
# cloning_capable_engine_ids() — a class-level getattr on a *property*
# (mlx-audio: capability depends on the picked model) returns the
# descriptor, not a bool, so report None (= model-dependent) there
# instead of an always-truthy false positive.
_clone = getattr(cls, "supports_cloning", True)
out.append({
"id": bid,
"display_name": cls.display_name,
"available": ok,
"reason": None if ok else _mask_hf_tokens(msg),
# Available-but-has-advice (e.g. VoxCPM2's ">=2.0.3 recommended"
# upgrade hint). None unless ok and the message carries advice.
"hint": _available_hint(msg) if ok else None,
"supports_cloning": _clone if isinstance(_clone, bool) else None,
"install_hint": _INSTALL_HINTS.get(bid),
# Exact `export VAR=...` line for path-gated opt-in engines, or None.
"setup_snippet": _SETUP_SNIPPETS.get(bid),
+10
View File
@@ -58,6 +58,10 @@ RUN uv pip install --system --no-cache .
# Copy application source
COPY backend/ ./backend/
COPY omnivoice/ ./omnivoice/
# Alembic config so schema migrations run natively on existing volumes
# (without it the backend fell back to the additive-column self-heal —
# functional, but the real migration chain is the first-class path).
COPY alembic.ini ./
# Copy the pre-built React frontend from the builder stage
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
@@ -65,6 +69,12 @@ COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Expose the single unified API and UI port
EXPOSE 3900
# Image-level health probe (compose files define their own; this covers plain
# `docker run`). Generous start period: first boot creates the venv-less
# schema + may pull model metadata before /health answers.
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=5 \
CMD curl -fsS http://127.0.0.1:3900/health || exit 1
# Mount points for persistent data (sqlite db, user voices, huggingface cache)
VOLUME ["/app/omnivoice_data"]
+1 -1
View File
@@ -13,7 +13,7 @@ and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palash
> |-----|--------------|
> | `:latest` | **Rolling preview** — latest commit on `main` (always one patch ahead of the last release). This is the preview channel; pin `:stable` for production. |
> | `:stable` | Most recent versioned release (updated on every `v*` git tag) |
> | `:0.3.6` | Exact release version |
> | `:0.3.17` | Exact release version |
> | `:0.3` | Latest patch within the 0.3 minor |
> | `:main` | Alias of the same rolling `main` build as `:latest` |
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
+5 -8
View File
@@ -12,13 +12,11 @@ working OmniVoice Studio install on a Debian / Ubuntu / Fedora / Arch host.
- **~10 GB free disk** for the app, its Python environment, and model weights.
- Optional: an **NVIDIA driver** for CUDA GPU acceleration — the app runs
CPU-only without one. For AMD GPUs see [AMD GPU (ROCm)](#amd-gpu-rocm).
- Optional: **yt-dlp** for downloading YouTube/video clips directly in the
Voice Gallery and Dub tabs — `sudo apt install yt-dlp` (Debian/Ubuntu),
`sudo dnf install yt-dlp` (Fedora), or `sudo pacman -S yt-dlp` (Arch).
Without it those downloads fail; everything else works fine.
That's it — Python, FFmpeg, and the model weights are bundled or bootstrapped
by the app itself on first launch. No toolchain needed.
That's it — Python, FFmpeg/FFprobe, yt-dlp, and the model weights are bundled
or bootstrapped by the app itself on first launch. No toolchain needed. (If no
FFmpeg resolves anywhere, the app downloads its own checksummed static build
in the background during setup; **Settings → Audio tools** shows exactly which
binaries are in use and lets you override them or update yt-dlp.)
### Building from source
@@ -29,7 +27,6 @@ Everything above, plus the toolchain:
- **Python 3.11+** — typically `sudo apt install python3.11` on Debian/Ubuntu,
`sudo dnf install python3.11` on Fedora, or already installed on Arch.
- **Bun**`curl -fsSL https://bun.sh/install | bash`.
- **FFmpeg**`sudo apt install ffmpeg` (Debian/Ubuntu), `sudo dnf install ffmpeg-free` (Fedora), or `sudo pacman -S ffmpeg` (Arch).
- **Rust / Cargo**`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or via your package manager (e.g., `sudo apt install rustc cargo`).
If you use rustup, reopen the shell or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
- **GTK/WebKit deps** for the Tauri shell:
+6 -1
View File
@@ -35,10 +35,15 @@ Everything above, plus the toolchain:
and the C toolchain; `curl` ships with macOS).
- **Python 3.11+**`brew install python@3.11` (or use `pyenv` / the system Python if you already have ≥3.11).
- **Bun**`curl -fsSL https://bun.sh/install | bash`.
- **FFmpeg** (used by the dubbing + capture pipelines) — `brew install ffmpeg`.
- **Rust / Cargo**`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or `brew install rust`.
If you use rustup, reopen the terminal or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
FFmpeg/FFprobe and yt-dlp are **not** prerequisites on any install path: the
app resolves them itself (a static build ships with the Python environment;
if nothing resolves, the app downloads its own checksummed build on first
run). Power users can inspect or override the binaries in
**Settings → Audio tools** — including pointing at a Homebrew copy.
Optional but recommended:
- **A Hugging Face account** for diarization and the larger TTS models. See
+26
View File
@@ -167,6 +167,32 @@ that rely on `/usr/bin/ffprobe`.
**Fix:** see [linux.md#deb-ffprobe-conflict](linux.md#deb-ffprobe-conflict).
## 7b. "Media engine unavailable" / FFmpeg questions
FFmpeg, FFprobe, and yt-dlp are **not** things you install for OmniVoice.
The app resolves them itself, in order: a path provided by the desktop shell →
the static build shipped with the Python environment → the app's own
downloaded build → whatever is on your PATH. When nothing resolves at all
(some source installs on a fresh machine), the Setup Wizard downloads a
pinned, checksum-verified static build in the background — you'll see a
one-line "Preparing media engine…" progress and, only if that download fails,
a card with **Retry** and **Use a system copy**.
If a running install ever reports "Media engine unavailable":
1. Open **Settings → Audio tools**. Each row shows the binary actually in use
(version, path, and origin — Bundled / System / Custom).
2. Press **Restore bundled** to re-fetch the app's own build (needs network
once), or **Use system copy** / **Choose file…** to point at an FFmpeg you
already have. Installing via a package manager (`brew install ffmpeg`,
`sudo apt install ffmpeg`, `winget install ffmpeg`) also works — press
**Use system copy** afterwards.
The same panel updates **yt-dlp** (video imports): site support changes
faster than app releases, so when video-URL imports start failing, press
**Update** there — the new version survives app updates, and **Restore tested
version** reverts to the build the app shipped with.
## 8. Docker LAN access — media preview 404
**Symptom:** OmniVoice loads on `http://<lan-ip>:3900` but the audio preview
+4 -1
View File
@@ -76,7 +76,10 @@ Settings → Sharing → **Remote backend**:
- **Test connection** hits `{url}/health` and shows the remote's version and
device.
- **Save & reload** stores both in this browser/app and restarts the UI
against the remote.
against the remote. The URL must be a full `http://` or `https://` URL
(`gpu-box:3900` alone is rejected), and saving a URL that hasn't passed
**Test connection** asks for confirmation first — a wrong base would leave
the app unable to reach any backend until you change it back here.
Leave the URL empty to go back to the local backend.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.3.16",
"version": "0.3.17",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+1 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.3.16"
version = "0.3.17"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.3.16"
version = "0.3.17"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
+46 -5
View File
@@ -83,7 +83,41 @@ pub fn same_app_version(running: &str) -> bool {
!running.is_empty() && base(running) == base(env!("CARGO_PKG_VERSION"))
}
/// Deep health probe for the attach-to-a-running-backend shortcut.
///
/// `/health` and `/system/info` keep answering from a backend whose install
/// was deleted out from under it (files unlinked on disk, code already in
/// memory) — that zombie passes the version check and then 500s every real
/// route, so the UI looks alive but nothing works. Probe a DB-touching
/// endpoint and require an actual `200` status line before attaching;
/// anything else (500, timeout, refused) means the responder is not a
/// backend worth keeping.
pub fn backend_deep_healthy(port: u16) -> bool {
let url = format!("http://127.0.0.1:{}/profiles", port);
match raw_http_get(&url, Duration::from_millis(1500)) {
Ok(resp) => parse_http_status(&resp) == Some(200),
Err(_) => false,
}
}
/// Status code from a raw HTTP response ("HTTP/1.1 200 OK" → 200).
fn parse_http_status(response: &str) -> Option<u16> {
let line = response.lines().next()?;
line.split_whitespace().nth(1)?.parse().ok()
}
fn ureq_get_with_timeout(url: &str, timeout: Duration) -> Result<String, String> {
let buf = raw_http_get(url, timeout)?;
if let Some(idx) = buf.find("\r\n\r\n") {
Ok(buf[idx + 4..].to_string())
} else {
Err("no body".into())
}
}
/// One raw loopback HTTP GET, returning the FULL response (status line +
/// headers + body). Kept dependency-free on purpose — see module docs.
fn raw_http_get(url: &str, timeout: Duration) -> Result<String, String> {
let url = url.strip_prefix("http://").ok_or("only http:// supported")?;
let (host_port, path) = match url.find('/') {
Some(i) => (&url[..i], &url[i..]),
@@ -112,11 +146,7 @@ fn ureq_get_with_timeout(url: &str, timeout: Duration) -> Result<String, String>
stream.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
let mut buf = String::new();
stream.read_to_string(&mut buf).map_err(|e| e.to_string())?;
if let Some(idx) = buf.find("\r\n\r\n") {
Ok(buf[idx + 4..].to_string())
} else {
Err("no body".into())
}
Ok(buf)
}
/// Kill whatever process owns the port.
@@ -428,6 +458,17 @@ mod tests {
assert_eq!(parse_app_version("<html>not json</html>"), None);
}
#[test]
fn parse_http_status_reads_the_status_line_only() {
assert_eq!(super::parse_http_status("HTTP/1.1 200 OK\r\nX: 500\r\n\r\nbody"), Some(200));
assert_eq!(
super::parse_http_status("HTTP/1.1 500 Internal Server Error\r\n\r\nInternal Server Error"),
Some(500)
);
assert_eq!(super::parse_http_status("garbage"), None);
assert_eq!(super::parse_http_status(""), None);
}
#[test]
fn same_app_version_matches_current_build_and_rejects_stale() {
let ours = env!("CARGO_PKG_VERSION");
+16 -4
View File
@@ -148,12 +148,24 @@ pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapS
}
match crate::backend::running_backend_version(backend_port()) {
Some(v) if crate::backend::same_app_version(&v) => {
log::info!(
"Port {} already serving OmniVoice backend v{} — attaching",
if crate::backend::backend_deep_healthy(backend_port()) {
log::info!(
"Port {} already serving OmniVoice backend v{} — attaching",
backend_port(), v
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
// Same version but a DB-touching probe fails: a backend whose
// install was wiped/corrupted while it kept running. Attaching
// would look alive and 500 on everything — replace it.
log::warn!(
"Port {} serves OmniVoice v{} but failed the deep health probe — replacing it",
backend_port(), v
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
set_backend_kill_intended(true); // deliberate kill, not a crash (#941)
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
Some(v) => {
// A healthy-but-stale backend from a previous version (the
+15 -4
View File
@@ -807,12 +807,23 @@ pub fn run() {
}
match backend::running_backend_version(backend_port()) {
Some(v) if backend::same_app_version(&v) => {
log::info!(
"Port {} already serving OmniVoice backend v{} — attaching",
if backend::backend_deep_healthy(backend_port()) {
log::info!(
"Port {} already serving OmniVoice backend v{} — attaching",
backend_port(), v
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
// Same version but a DB-touching probe fails: a backend whose
// install was wiped/corrupted while it kept running. Attaching
// would look alive and 500 on everything — replace it.
log::warn!(
"Port {} serves OmniVoice v{} but failed the deep health probe — replacing it",
backend_port(), v
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
Some(v) => {
// Healthy-but-stale backend from a previous version —
+32
View File
@@ -88,6 +88,38 @@ export async function modelStatus(): Promise<ModelStatus> {
return apiJson<ModelStatus>('/model/status');
}
// ── Loaded-model residency (MM2-04 endpoints) ────────────────────────────
/** One entry from GET /model/loaded a model currently resident in memory.
* `engine_id`/`is_active_engine` attribute TTS-family entries to an engine
* (a model can stay resident after the user switches engines). */
export interface LoadedModel {
id: string; // 'tts' | 'asr' | 'diarization' | 'sidecar:<engine>'
name: string;
checkpoint: string;
device: string;
vram_mb: number;
unloadable: boolean;
note?: string;
engine_id?: string;
is_active_engine?: boolean | null;
}
export interface LoadedModelsResponse {
models: LoadedModel[];
count: number;
}
export async function listLoadedModels(): Promise<LoadedModelsResponse> {
return apiJson<LoadedModelsResponse>('/model/loaded');
}
/** Unload one resident model by its /model/loaded `id`. The model reloads
* lazily on next use unloading only frees memory, it never loses data. */
export async function unloadLoadedModel(modelId: string): Promise<unknown> {
return apiPost(`/model/unload/${encodeURIComponent(modelId)}`);
}
// ── Audio cleaning ───────────────────────────────────────────────────────
export async function cleanAudio(formData: FormData): Promise<Response> {
+8
View File
@@ -29,6 +29,14 @@ interface EngineBackend {
display_name: string;
available: boolean;
reason: string | null;
// Available-but-has-advice: the backend's `is_available()` returned ok with
// an advisory tail ("ready — <advice>", e.g. VoxCPM2's upgrade hint). Null
// for plain-ready and unavailable rows; absent on legacy payloads.
hint?: string | null;
// Cloning capability (TTS family): true/false from the backend class, null
// when model-dependent (mlx-audio's curated models differ). Only badge on
// an explicit true.
supports_cloning?: boolean | null;
install_hint?: string | null;
// Copy-paste-ready `export VAR=...` line for a path-gated opt-in engine
// (IndexTTS / MOSS-v1.5 / dots.tts / Confucius4), else null/absent.
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { cn } from '@/lib/utils';
/**
* EngineMark the per-engine identity mark for the Models & Engines
* Settings surfaces.
*
* A small monogram chip whose hue is derived deterministically from the
* engine id (the same trick as `models/format.js`'s `orgColor` for HF
* orgs), so the same engine is instantly recognizable everywhere it
* appears on these pages: the Engine Compatibility Matrix rows and the
* "in memory" residency chips. Purely decorative (`aria-hidden`) the
* engine's name and id are always rendered as text alongside it.
*
* Theme-safe by construction: the hue is fixed per engine, but the fill
* is a low-opacity `color-mix` over transparent and the glyph color is
* mixed toward `--chrome-fg`, so it stays legible on light and dark
* themes without per-theme overrides.
*/
/** Deterministic hue (0359) from an engine id. */
export function engineHue(id) {
const s = String(id || '');
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
return h % 360;
}
/** Two-character monogram from an engine id ("mlx-audio" "MA",
* "voxcpm2" "VO"). Falls back to "?" for an empty id. */
export function engineMonogram(id) {
const parts = String(id || '')
.split(/[^a-z0-9]+/i)
.filter(Boolean);
if (parts.length === 0) return '?';
const mono = parts.length >= 2 ? parts[0][0] + parts[1][0] : parts[0].slice(0, 2);
return mono.toUpperCase();
}
export default function EngineMark({ id, size = 20, className = '' }) {
const accent = `hsl(${engineHue(id)} 62% 52%)`;
return (
<span
aria-hidden="true"
data-testid={`engine-mark-${id}`}
className={cn(
'inline-flex shrink-0 select-none items-center justify-center rounded-[5px] font-semibold tracking-[0.02em]',
className,
)}
style={{
width: size,
height: size,
fontSize: Math.max(8, Math.round(size * 0.42)),
background: `color-mix(in srgb, ${accent} 15%, transparent)`,
border: `1px solid color-mix(in srgb, ${accent} 40%, transparent)`,
color: `color-mix(in srgb, ${accent} 55%, var(--chrome-fg, currentColor))`,
}}
>
{engineMonogram(id)}
</span>
);
}
+3
View File
@@ -798,6 +798,9 @@ export default function LogsFooter() {
if (notif.action.type === 'navigate') {
useAppStore.getState().setMode?.(notif.action.target);
setCollapsed(true);
} else if (notif.action.type === 'settings-tab') {
useAppStore.getState().openSettingsTab?.(notif.action.target);
setCollapsed(true);
} else if (notif.action.type === 'link') {
import('../api/external').then((m) => m.openExternal(notif.action.target));
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Media engine invisible unless it needs help.
*
* The media engine (ffmpeg/ffprobe) is an internal dependency, not a system
* requirement: when the backend's resolution chain finds nothing, preflight
* already kicked a background download of the app's own pinned static build.
* This renders NOTHING when the engine is ready (the ideal outcome), a quiet
* one-line progress while acquiring, and an actionable card only on failure
* (Retry / use a copy already on the machine). yt-dlp never appears here
* it's an importable module, not a user task.
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from 'lucide-react';
import { apiJson, apiFetch } from '../api/client';
import { Button } from '../ui';
export default function MediaEngineCard() {
const { t } = useTranslation();
const [status, setStatus] = useState(null);
const [detectError, setDetectError] = useState(null);
const [customPath, setCustomPath] = useState('');
const [showPathInput, setShowPathInput] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const st = await apiJson('/media-tools/status');
setStatus(st);
return st;
} catch {
return null;
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const acquiring = status?.ops?.acquire?.state === 'running';
useEffect(() => {
if (!acquiring) return undefined;
const iv = setInterval(refresh, 1500);
return () => clearInterval(iv);
}, [acquiring, refresh]);
const post = async (path, body) => {
setBusy(true);
setDetectError(null);
try {
const res = await apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let detail = `HTTP ${res.status}`;
try {
detail = (await res.json())?.detail || detail;
} catch {
/* non-JSON body */
}
throw new Error(detail);
}
return true;
} catch (e) {
setDetectError(e?.message || String(e));
return false;
} finally {
setBusy(false);
refresh();
}
};
const useSystemCopy = async () => {
// ffprobe rides along: the resolver derives the sibling ffprobe from a
// resolved ffmpeg, so pinning ffmpeg is enough in the common case.
await post('/media-tools/ffmpeg/use-system');
};
const chooseFile = async () => {
try {
if ('__TAURI_INTERNALS__' in window) {
const { open } = await import('@tauri-apps/plugin-dialog');
const picked = await open({ multiple: false, directory: false, title: 'FFmpeg' });
if (typeof picked === 'string') {
await post('/media-tools/ffmpeg/custom-path', { path: picked });
return;
}
}
} catch {
/* picker unavailable — fall through to the inline input */
}
setShowPathInput(true);
};
if (!status || status.ready) return null; // the ideal outcome: nothing.
const op = status.ops?.acquire || {};
if (op.state === 'running' || op.state === 'idle') {
// idle-and-not-ready = preflight is about to kick the download (or a
// recheck is in flight) show the quiet line, never flash the card.
return (
<div
className="mt-3 flex items-center gap-2 text-xs text-fg-muted"
data-testid="media-engine-progress"
>
<Loader className="animate-spin" size={12} aria-hidden="true" />
{t('setup.media_engine_preparing', { defaultValue: 'Preparing media engine…' })}
{op.state === 'running' && ` ${Math.round((op.progress || 0) * 100)}%`}
</div>
);
}
return (
<div
className="mt-3 flex flex-col gap-1.5 rounded-md border border-border px-3 py-2.5"
data-testid="media-engine-card"
>
<span className="text-sm font-semibold">
{t('setup.media_engine_failed_title', { defaultValue: 'Media engine download failed' })}
</span>
<span className="text-xs leading-snug text-fg-muted">
{t('setup.media_engine_failed_desc', {
defaultValue:
"The app couldn't fetch its bundled audio/video engine (FFmpeg). Retry, or point it at a copy already on this computer.",
})}
</span>
{(op.error || detectError) && (
<span className="text-xs text-danger" role="alert" data-testid="media-engine-error">
{detectError || op.error}
</span>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
<Button
variant="subtle"
size="sm"
loading={busy}
disabled={busy}
onClick={() => post('/media-tools/acquire')}
data-testid="media-engine-retry"
>
{t('setup.media_engine_retry', { defaultValue: 'Retry' })}
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={useSystemCopy}
data-testid="media-engine-use-system"
>
{t('setup.media_engine_use_system', { defaultValue: 'Use a system copy' })}
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={chooseFile}>
{t('setup.media_engine_choose_file', { defaultValue: 'Choose file…' })}
</Button>
{showPathInput && (
<>
<input
type="text"
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
placeholder="/usr/bin/ffmpeg"
className="min-w-[220px] flex-1 rounded border border-border bg-transparent px-2 py-1 font-mono text-xs text-fg"
aria-label={t('settings.ffmpeg_input_aria', { defaultValue: 'FFmpeg path' })}
data-testid="media-engine-path"
/>
<Button
variant="subtle"
size="sm"
disabled={busy || !customPath.trim()}
onClick={() => post('/media-tools/ffmpeg/custom-path', { path: customPath.trim() })}
>
{t('credentials.save', { defaultValue: 'Save' })}
</Button>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,100 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('../api/client', () => ({
apiJson: vi.fn(),
apiFetch: vi.fn(),
}));
import { apiJson, apiFetch } from '../api/client';
import MediaEngineCard from './MediaEngineCard';
const statusWith = (ready, acquire) => ({
ready,
tools: {},
ops: { acquire: acquire || { state: 'idle', progress: 0, error: null } },
});
describe('MediaEngineCard — invisible-by-default media engine', () => {
beforeEach(() => {
vi.clearAllMocks();
apiFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
});
it('renders NOTHING when the media engine is resolved (the ideal outcome)', async () => {
apiJson.mockResolvedValue(statusWith(true));
const { container } = render(<MediaEngineCard />);
await waitFor(() => expect(apiJson).toHaveBeenCalledWith('/media-tools/status'));
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId('media-engine-card')).not.toBeInTheDocument();
});
it('shows only a quiet progress line while the bundled build downloads', async () => {
apiJson.mockResolvedValue(statusWith(false, { state: 'running', progress: 0.42, error: null }));
render(<MediaEngineCard />);
const line = await screen.findByTestId('media-engine-progress');
expect(line).toHaveTextContent('Preparing media engine…');
expect(line).toHaveTextContent('42%');
// No requirements-style card, no mention of package managers.
expect(screen.queryByTestId('media-engine-card')).not.toBeInTheDocument();
expect(document.body.textContent).not.toMatch(/brew|apt|choco/i);
});
it('shows the actionable failure card only when acquisition failed', async () => {
apiJson.mockResolvedValue(
statusWith(false, { state: 'error', progress: 0, error: 'download checksum mismatch' }),
);
render(<MediaEngineCard />);
const card = await screen.findByTestId('media-engine-card');
expect(card).toHaveTextContent('Media engine download failed');
expect(screen.getByTestId('media-engine-error')).toHaveTextContent(
'download checksum mismatch',
);
expect(screen.getByTestId('media-engine-retry')).toBeInTheDocument();
expect(screen.getByTestId('media-engine-use-system')).toBeInTheDocument();
});
it('Retry re-posts the acquisition endpoint', async () => {
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
render(<MediaEngineCard />);
fireEvent.click(await screen.findByTestId('media-engine-retry'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/acquire', expect.anything()),
);
});
it('Use a system copy posts use-system and surfaces a not-found detail', async () => {
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
apiFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({
detail: 'No system ffmpeg found on PATH or in the usual install locations.',
}),
});
render(<MediaEngineCard />);
fireEvent.click(await screen.findByTestId('media-engine-use-system'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffmpeg/use-system', expect.anything()),
);
expect(await screen.findByTestId('media-engine-error')).toHaveTextContent(
'No system ffmpeg found',
);
});
it('Choose file… falls back to an inline path input outside Tauri and saves it', async () => {
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
render(<MediaEngineCard />);
fireEvent.click(await screen.findByText('Choose file…'));
const input = await screen.findByTestId('media-engine-path');
fireEvent.change(input, { target: { value: '/usr/local/bin/ffmpeg' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
'/media-tools/ffmpeg/custom-path',
expect.objectContaining({ body: JSON.stringify({ path: '/usr/local/bin/ffmpeg' }) }),
),
);
});
});
+55 -10
View File
@@ -12,12 +12,50 @@ import {
import { useTranslation } from 'react-i18next';
import { openExternal } from '../../api/external';
import { resolveAboutVersion } from '../../utils/appVersion';
import { REPO_URL } from '../../utils/bugReport';
import { Button, Badge } from '../../ui';
import { SettingsSection } from './primitives';
import { CATEGORY_BY_ID } from './settingsCategories';
import { useAppStore } from '../../store';
import { isTauri } from './native';
import Row from './Row';
/**
* Where a failing self-check can be fixed inside the app diagnose check id
* (backend/core/diagnose.py) Settings category id. Checks without an in-app
* fix (python, backend, ) render their hint as plain text only.
*/
const CHECK_FIX_CATEGORY = {
ffmpeg: 'network',
hf_token: 'credentials',
disk: 'storage',
data_dir: 'storage',
engines: 'engines',
gpu_routing: 'engines',
device: 'performance',
ram: 'performance',
deep_synth: 'logs',
};
/** Small "Open <category>" deep-link into the Settings hub. */
function OpenCategoryButton({ categoryId }) {
const { t } = useTranslation();
const cat = CATEGORY_BY_ID[categoryId];
if (!cat) return null;
return (
<Button
size="sm"
variant="subtle"
onClick={() => useAppStore.getState().openSettingsTab(categoryId)}
>
{t('about.open_fix_category', {
defaultValue: 'Open {{category}}',
category: t(cat.labelKey, { defaultValue: cat.defaultLabel }),
})}
</Button>
);
}
/**
* Settings About.
*
@@ -52,7 +90,16 @@ export default function AboutTab({
/>
<Row
label={t('about.hf_token')}
value={info?.has_hf_token ? t('about.yes') : t('about.no')}
value={
info?.has_hf_token ? (
t('about.yes')
) : (
<span className="inline-flex flex-wrap items-center gap-[var(--space-3)]">
{t('about.no')}
<OpenCategoryButton categoryId="credentials" />
</span>
)
}
/>
<div className="settings-link-row mt-[var(--space-5)] flex flex-wrap gap-[var(--space-4)]">
@@ -92,18 +139,10 @@ export default function AboutTab({
variant="subtle"
size="md"
leading={<ExternalLink size={12} />}
onClick={() => openExternal('https://github.com/k2-fsa/OmniVoice')}
onClick={() => openExternal(REPO_URL)}
>
{t('about.github')}
</Button>
<Button
variant="subtle"
size="md"
leading={<ExternalLink size={12} />}
onClick={() => openExternal('https://huggingface.co/k2-fsa/OmniVoice')}
>
{t('about.model_card')}
</Button>
<Button
variant="subtle"
size="md"
@@ -136,6 +175,12 @@ export default function AboutTab({
{c.hint}
</span>
)}
{c.status !== 'ok' && CHECK_FIX_CATEGORY[c.id] && (
<>
{' '}
<OpenCategoryButton categoryId={CHECK_FIX_CATEGORY[c.id]} />
</>
)}
</span>
}
/>
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import AboutTab from './AboutTab';
import { REPO_URL } from '../../utils/bugReport';
import { openExternal } from '../../api/external';
import { useAppStore } from '../../store';
vi.mock('../../api/external', () => ({ openExternal: vi.fn() }));
const noop = () => {};
const baseProps = {
appVersion: '0.0.0-test',
tauriVersion: null,
info: { has_hf_token: true },
checkForUpdates: noop,
updateState: 'idle',
selfCheck: null,
selfCheckRunning: false,
runSelfCheck: noop,
bundleBuilding: false,
saveDiagnosticBundle: noop,
copyDiagnostics: noop,
};
describe('AboutTab — external links', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('the GitHub button opens the canonical repo (derived from the shared REPO_URL constant)', () => {
render(<AboutTab {...baseProps} />);
fireEvent.click(screen.getByRole('button', { name: 'OmniVoice on GitHub' }));
expect(openExternal).toHaveBeenCalledWith(REPO_URL);
// Belt-and-braces: the constant itself must point at this project, not a
// lookalike (the original bug linked github.com/k2-fsa/OmniVoice).
expect(REPO_URL).toBe('https://github.com/debpalash/OmniVoice-Studio');
});
it('has no "Model card" link — the app is multi-engine with no single model card', () => {
render(<AboutTab {...baseProps} />);
expect(screen.queryByRole('button', { name: /model card/i })).toBeNull();
});
});
describe('AboutTab — fixable problems deep-link into Settings', () => {
beforeEach(() => {
vi.clearAllMocks();
useAppStore.getState().setMode('launchpad');
useAppStore.getState().setPendingSettingsTab(null);
});
it('HF token "no" offers an Open Credentials action instead of dead-ending', () => {
render(<AboutTab {...baseProps} info={{ has_hf_token: false }} />);
fireEvent.click(screen.getByRole('button', { name: 'Open Credentials' }));
expect(useAppStore.getState().mode).toBe('settings');
expect(useAppStore.getState().pendingSettingsTab).toBe('credentials');
});
it('HF token "yes" renders no Credentials action', () => {
render(<AboutTab {...baseProps} info={{ has_hf_token: true }} />);
expect(screen.queryByRole('button', { name: 'Open Credentials' })).toBeNull();
});
it('a failing self-check renders an "Open <category>" button for its fix destination', () => {
const selfCheck = {
checks: [
{
id: 'ffmpeg',
label: 'ffmpeg',
status: 'fail',
detail: 'not found on PATH or FFMPEG_PATH',
hint: 'Dubbing and audio conversion need ffmpeg.',
},
{ id: 'python', label: 'Python runtime', status: 'ok', detail: '3.12', hint: null },
],
summary: { ok: false, failures: 1 },
};
render(<AboutTab {...baseProps} selfCheck={selfCheck} />);
fireEvent.click(screen.getByRole('button', { name: 'Open Network' }));
expect(useAppStore.getState().mode).toBe('settings');
expect(useAppStore.getState().pendingSettingsTab).toBe('network');
});
it('passing checks render no deep-link button', () => {
const selfCheck = {
checks: [
{ id: 'ffmpeg', label: 'ffmpeg', status: 'ok', detail: '/usr/bin/ffmpeg', hint: null },
],
summary: { ok: true, failures: 0 },
};
render(<AboutTab {...baseProps} selfCheck={selfCheck} />);
expect(screen.queryByRole('button', { name: /^Open / })).toBeNull();
});
});
+9 -14
View File
@@ -7,39 +7,34 @@
* while OmniVoice plays audio doesn't transcribe the playback. Off by default
* dictation uses the standard MediaRecorder path and behaves identically on
* every platform. The pref is the zustand `aecEnabled` flag (persisted); no
* backend round-trip needed.
* backend round-trip needed. All strings go through i18n (`dictation.aec_*`).
*/
import React from 'react';
import { Volume2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store';
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
export default function AecPanel() {
const { t } = useTranslation();
const aecEnabled = useAppStore((s) => s.aecEnabled);
const setAecEnabled = useAppStore((s) => s.setAecEnabled);
return (
<SettingsSection
icon={Volume2}
title="Dictate while audio plays"
description="Cancel OmniVoice's own playback out of the microphone."
title={t('dictation.aec_title')}
description={t('dictation.aec_description')}
>
<SettingRow
title="Enable echo cancellation for dictation"
subtitle="experimental"
hint={
<>
Cancels OmniVoice's own playback out of the microphone so you can dictate while a
preview, dub, or video is playing without the transcript picking up what the app is
saying. Adds a small amount of audio processing; leave it off if you never dictate over
playback.
</>
}
title={t('dictation.aec_row_title')}
subtitle={t('dictation.aec_experimental')}
hint={t('dictation.aec_hint')}
control={
<SettingsToggle
checked={aecEnabled}
onChange={setAecEnabled}
aria-label="Enable echo cancellation for dictation"
aria-label={t('dictation.aec_row_title')}
/>
}
/>
+140 -122
View File
@@ -25,15 +25,6 @@ import { CheckCircle2, KeyRound, RefreshCw, Save, Trash2, XCircle } from 'lucide
import { apiJson, apiPost, apiFetch, API } from '../../api/client';
import { SettingsSection, InfoHint } from './primitives';
const EMPTY_STATE = {
sources: [
{ source: 'app', set: false, masked: null, whoami_user: null, whoami_ok: false },
{ source: 'env', set: false, masked: null, whoami_user: null, whoami_ok: false },
{ source: 'hf-cli', set: false, masked: null, whoami_user: null, whoami_ok: false },
],
active: null,
};
export default function ApiKeysPanel() {
const { t } = useTranslation();
const SOURCE_LABELS = {
@@ -52,7 +43,9 @@ export default function ApiKeysPanel() {
defaultValue: 'Written by `huggingface-cli login`. Read-only from the UI.',
}),
};
const [state, setState] = useState(EMPTY_STATE);
// null until the first GET lands the panel renders a "checking" placeholder
// instead of flashing a false amber "not set" verdict for every source.
const [state, setState] = useState(null);
const [loading, setLoading] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [saving, setSaving] = useState(false);
@@ -60,27 +53,36 @@ export default function ApiKeysPanel() {
const [alsoClearCli, setAlsoClearCli] = useState(false);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await apiJson('/api/settings/hf-token/state');
setState(data);
} catch (e) {
setError(
e?.message ||
t('settings.hf_token_load_error', { defaultValue: 'Failed to load token state' }),
);
} finally {
setLoading(false);
}
}, [t]);
// `fresh` busts the backend's 300s whoami cache used by "Test now" so it
// really re-runs whoami instead of echoing a cached (possibly stale) verdict.
// Plain mounts/refreshes keep the cache so Settings visits stay cheap.
const refresh = useCallback(
async ({ fresh = false } = {}) => {
setLoading(true);
setError(null);
try {
const data = await apiJson(`/api/settings/hf-token/state${fresh ? '?fresh=1' : ''}`);
setState(data);
} catch (e) {
setError(
e?.message ||
t('settings.hf_token_load_error', { defaultValue: 'Failed to load token state' }),
);
} finally {
setLoading(false);
}
},
[t],
);
useEffect(() => {
refresh();
}, [refresh]);
const onSave = async () => {
// `saving` mirrors the button's disabled state for the input's Enter path,
// closing the double-submit hole (Enter fired POSTs while one was in flight).
if (saving) return;
const token = tokenInput.trim();
if (!token) return;
setSaving(true);
@@ -131,7 +133,7 @@ export default function ApiKeysPanel() {
<button
type="button"
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-transparent px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-fg)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={refresh}
onClick={() => refresh({ fresh: true })}
disabled={loading}
aria-label={testNowLabel}
title={t('settings.hf_token_test_now_title', {
@@ -151,106 +153,122 @@ export default function ApiKeysPanel() {
</div>
)}
<div
className="flex flex-col gap-[var(--space-3)]"
role="table"
aria-label={t('settings.hf_token_sources', { defaultValue: 'HF token sources' })}
>
{state.sources.map((row) => {
const isActive = state.active === row.source;
return (
<div
key={row.source}
className={`apikeys-row ${isActive ? 'apikeys-row--active' : ''}`}
role="row"
data-source={row.source}
>
<div className="flex items-center justify-between gap-[var(--space-3)]">
<span className="inline-flex items-center gap-[var(--space-2)] text-[length:var(--text-md)] font-medium text-[var(--chrome-fg)]">
{SOURCE_LABELS[row.source]}
<InfoHint>{SOURCE_HELP[row.source]}</InfoHint>
</span>
{isActive && (
<span className="apikeys-badge apikeys-badge--active">
{t('settings.hf_token_active', { defaultValue: 'Active' })}
{!state ? (
// First load still in flight (or failed the banner above explains and
// "Test now" doubles as retry). Never show a wrong "not set" verdict.
<div
className="py-[var(--space-4)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]"
role="status"
data-testid="hf-token-loading"
>
{loading && t('settings.hf_token_checking', { defaultValue: 'Checking token sources…' })}
</div>
) : (
/* Visually a stack of cards, not a data grid list semantics are the
valid ARIA fit (the old role="table" had rows with no cells). */
<div
className="flex flex-col gap-[var(--space-3)]"
role="list"
aria-label={t('settings.hf_token_sources', { defaultValue: 'HF token sources' })}
>
{state.sources.map((row) => {
const isActive = state.active === row.source;
return (
<div
key={row.source}
className={`apikeys-row ${isActive ? 'apikeys-row--active' : ''}`}
role="listitem"
data-source={row.source}
>
<div className="flex items-center justify-between gap-[var(--space-3)]">
<span className="inline-flex items-center gap-[var(--space-2)] text-[length:var(--text-md)] font-medium text-[var(--chrome-fg)]">
{SOURCE_LABELS[row.source]}
<InfoHint>{SOURCE_HELP[row.source]}</InfoHint>
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-[var(--space-3)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
{row.set ? (
<>
<span
className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-ok)]"
aria-label={t('settings.hf_token_set', { defaultValue: 'set' })}
>
<CheckCircle2 size={12} />{' '}
{t('settings.hf_token_set', { defaultValue: 'set' })}
{isActive && (
<span className="apikeys-badge apikeys-badge--active">
{t('settings.hf_token_active', { defaultValue: 'Active' })}
</span>
{row.masked && (
<code className="rounded-[4px] bg-[var(--chrome-hover-bg)] px-[6px] py-[1px] font-mono text-[length:var(--text-xs)]">
{row.masked}
</code>
)}
{row.whoami_ok ? (
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-ok)]">
<CheckCircle2 size={12} />{' '}
{row.whoami_user ||
t('settings.hf_token_verified', { defaultValue: 'verified' })}
</span>
) : (
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-err)]">
<XCircle size={12} />{' '}
{t('settings.hf_token_whoami_failed', { defaultValue: 'whoami failed' })}
</span>
)}
</>
) : (
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-warn)]">
<XCircle size={12} />{' '}
{t('settings.hf_token_not_set', { defaultValue: 'not set' })}
</span>
)}
</div>
{row.source === 'app' && (
<div className="mt-[var(--space-2)] flex flex-wrap items-center gap-[var(--space-3)]">
<input
type="password"
className="box-border min-w-0 max-w-full flex-[1_1_220px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] font-mono text-[length:var(--text-sm)] text-[var(--chrome-fg)] focus:border-[var(--chrome-accent)] focus:outline-none"
placeholder="hf_…"
aria-label={t('settings.hf_token_input', { defaultValue: 'HuggingFace token' })}
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSave();
}}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-accent)] bg-[color-mix(in_srgb,var(--chrome-accent)_25%,var(--chrome-bg))] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-fg)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={onSave}
disabled={!tokenInput.trim() || saving}
>
<Save size={12} /> {t('common.save')}
</button>
{row.set && (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_color-mix(in_srgb,var(--chrome-severity-err)_35%,var(--chrome-border))] bg-[var(--chrome-bg)] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-severity-err)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => setClearOpen(true)}
disabled={saving}
>
<Trash2 size={12} />{' '}
{t('settings.hf_token_clear_short', { defaultValue: 'Clear' })}
</button>
)}
</div>
)}
</div>
);
})}
</div>
<div className="flex flex-wrap items-center gap-[var(--space-3)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
{row.set ? (
<>
<span
className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-ok)]"
aria-label={t('settings.hf_token_set', { defaultValue: 'set' })}
>
<CheckCircle2 size={12} />{' '}
{t('settings.hf_token_set', { defaultValue: 'set' })}
</span>
{row.masked && (
<code className="rounded-[4px] bg-[var(--chrome-hover-bg)] px-[6px] py-[1px] font-mono text-[length:var(--text-xs)]">
{row.masked}
</code>
)}
{row.whoami_ok ? (
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-ok)]">
<CheckCircle2 size={12} />{' '}
{row.whoami_user ||
t('settings.hf_token_verified', { defaultValue: 'verified' })}
</span>
) : (
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-err)]">
<XCircle size={12} />{' '}
{t('settings.hf_token_whoami_failed', { defaultValue: 'whoami failed' })}
</span>
)}
</>
) : (
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-warn)]">
<XCircle size={12} />{' '}
{t('settings.hf_token_not_set', { defaultValue: 'not set' })}
</span>
)}
</div>
{row.source === 'app' && (
<div className="mt-[var(--space-2)] flex flex-wrap items-center gap-[var(--space-3)]">
<input
type="password"
className="box-border min-w-0 max-w-full flex-[1_1_220px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] font-mono text-[length:var(--text-sm)] text-[var(--chrome-fg)] focus:border-[var(--chrome-accent)] focus:outline-none"
placeholder="hf_…"
aria-label={t('settings.hf_token_input', {
defaultValue: 'HuggingFace token',
})}
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSave();
}}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-accent)] bg-[color-mix(in_srgb,var(--chrome-accent)_25%,var(--chrome-bg))] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-fg)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={onSave}
disabled={!tokenInput.trim() || saving}
>
<Save size={12} /> {t('common.save')}
</button>
{row.set && (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_color-mix(in_srgb,var(--chrome-severity-err)_35%,var(--chrome-border))] bg-[var(--chrome-bg)] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-severity-err)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => setClearOpen(true)}
disabled={saving}
>
<Trash2 size={12} />{' '}
{t('settings.hf_token_clear_short', { defaultValue: 'Clear' })}
</button>
)}
</div>
)}
</div>
);
})}
</div>
)}
{clearOpen && (
<div
@@ -150,7 +150,7 @@ describe('ApiKeysPanel', () => {
});
});
it('"Test now" button refetches state', async () => {
it('"Test now" busts the whoami cache (?fresh=1); plain mounts stay cached', async () => {
const fetchMock = mockFetchSequence(
{ status: 200, body: STATE_THREE_UNSET },
{ status: 200, body: STATE_THREE_UNSET },
@@ -159,11 +159,88 @@ describe('ApiKeysPanel', () => {
render(<ApiKeysPanel />);
await waitFor(() => screen.getByPlaceholderText(/hf_/));
// Mount GET keeps the backend cache no fresh param.
expect(fetchMock.mock.calls[0][0]).not.toMatch(/fresh=1/);
const testBtn = screen.getByRole('button', { name: /test now/i });
fireEvent.click(testBtn);
// The button claims to re-run whoami, so it must actually bypass the
// backend's 300s validation cache.
await waitFor(() => {
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(fetchMock.mock.calls[1][0]).toMatch(/\/api\/settings\/hf-token\/state\?fresh=1$/);
});
});
it('initial load shows a checking placeholder, never a false "not set" verdict', async () => {
let resolveFetch;
global.fetch = vi.fn(
() =>
new Promise((resolve) => {
resolveFetch = resolve;
}),
);
const { container } = render(<ApiKeysPanel />);
// While the GET is in flight: placeholder, no source rows, no verdicts.
expect(screen.getByTestId('hf-token-loading')).toBeInTheDocument();
expect(screen.queryByText(/not set/i)).toBeNull();
expect(container.querySelectorAll('.apikeys-row').length).toBe(0);
resolveFetch({
ok: true,
status: 200,
json: async () => STATE_APP_ACTIVE,
text: async () => JSON.stringify(STATE_APP_ACTIVE),
});
await waitFor(() => {
expect(container.querySelectorAll('.apikeys-row').length).toBe(3);
expect(screen.queryByTestId('hf-token-loading')).toBeNull();
});
});
it('renders the sources as a valid ARIA list (no cell-less table)', async () => {
global.fetch = mockFetchOnce(STATE_THREE_UNSET);
render(<ApiKeysPanel />);
const list = await screen.findByRole('list', { name: /HF token sources/i });
expect(list.querySelectorAll('[role="listitem"]').length).toBe(3);
});
it('Enter while a save is in flight does not fire a duplicate POST', async () => {
let resolvePost;
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => STATE_THREE_UNSET,
text: async () => JSON.stringify(STATE_THREE_UNSET),
})
.mockImplementation(
() =>
new Promise((resolve) => {
resolvePost = resolve;
}),
);
global.fetch = fetchMock;
render(<ApiKeysPanel />);
const input = await screen.findByPlaceholderText(/hf_/);
fireEvent.change(input, { target: { value: 'hf_newtoken123' } });
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.keyDown(input, { key: 'Enter' }); // Save button is disabled; Enter must be too.
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
const posts = fetchMock.mock.calls.filter(([, opts]) => opts?.method === 'POST');
expect(posts.length).toBe(1);
});
resolvePost({
ok: true,
status: 200,
json: async () => STATE_APP_ACTIVE,
text: async () => JSON.stringify(STATE_APP_ACTIVE),
});
});
});
@@ -21,6 +21,39 @@ const THEMES = [
{ id: 'catppuccin', label: 'Catppuccin', dot: '#cba6f7' },
];
/**
* WAI-ARIA radio-group keyboard support for the theme-dot / font-tile pickers:
* arrow keys move selection (wrapping), Home/End jump to the ends, and focus
* follows selection. Pair with `radioTabIndex` for the roving tabindex so the
* group occupies a single tab stop, as the announced role promises.
*/
function radioGroupKeyDown(e, values, current, select) {
const STEP = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
let next;
if (e.key in STEP) {
const idx = Math.max(0, values.indexOf(current));
next = values[(idx + STEP[e.key] + values.length) % values.length];
} else if (e.key === 'Home') {
next = values[0];
} else if (e.key === 'End') {
next = values[values.length - 1];
}
if (!next) return;
e.preventDefault();
select(next);
const el = e.currentTarget
.closest('[role="radiogroup"]')
?.querySelector(`[data-radio-value="${next}"]`);
el?.focus();
}
/** Roving tabindex: only the checked radio (or the first, if none is checked
* e.g. a stale persisted value) is tabbable. */
function radioTabIndex(values, current, value) {
const focusable = values.includes(current) ? current : values[0];
return value === focusable ? 0 : -1;
}
export default function AppearancePanel() {
const { t } = useTranslation();
const uiScale = useAppStore((s) => s.uiScale);
@@ -37,6 +70,8 @@ export default function AppearancePanel() {
const scaleLabel = t('settings.ui_scale', { defaultValue: 'UI scale' });
const themeLabel = t('settings.color_theme', { defaultValue: 'Color theme' });
const fontLabel = t('settings.font', { defaultValue: 'Font' });
const themeIds = THEMES.map((th) => th.id);
const fontIds = FONT_OPTIONS.map((f) => f.id);
return (
<SettingsSection
@@ -89,10 +124,13 @@ export default function AppearancePanel() {
className={`appearance-panel__theme-dot ${theme === th.id ? 'is-active' : ''}`}
style={{ '--dot-color': th.dot }}
onClick={() => setTheme(th.id)}
onKeyDown={(e) => radioGroupKeyDown(e, themeIds, theme, setTheme)}
title={th.label}
aria-label={th.label}
aria-checked={theme === th.id}
role="radio"
tabIndex={radioTabIndex(themeIds, theme, th.id)}
data-radio-value={th.id}
/>
))}
</div>
@@ -117,10 +155,13 @@ export default function AppearancePanel() {
role="radio"
aria-checked={font === f.id}
aria-label={f.label}
tabIndex={radioTabIndex(fontIds, font, f.id)}
data-radio-value={f.id}
data-testid={`appearance-font-${f.id}`}
className={`appearance-panel__font-tile ${font === f.id ? 'is-active' : ''}`}
style={{ fontFamily: FONT_STACKS[f.id] || 'var(--font-sans)' }}
onClick={() => setFont(f.id)}
onKeyDown={(e) => radioGroupKeyDown(e, fontIds, font, setFont)}
>
<span className="appearance-panel__font-sample">Ag</span>
<span className="appearance-panel__font-name">{f.label}</span>
@@ -50,6 +50,56 @@ describe('AppearancePanel — global font selection', () => {
});
});
describe('AppearancePanel — WAI-ARIA radio-group keyboard pattern', () => {
const fontIds = FONT_OPTIONS.map((f) => f.id);
beforeEach(() => {
useAppStore.getState().setFont(fontIds[0]);
useAppStore.getState().setTheme('gruvbox');
document.documentElement.style.removeProperty('--font-sans');
});
it('roving tabindex: only the checked font tile is tabbable', () => {
render(<AppearancePanel />);
expect(screen.getByTestId(`appearance-font-${fontIds[0]}`)).toHaveAttribute('tabindex', '0');
for (const id of fontIds.slice(1)) {
expect(screen.getByTestId(`appearance-font-${id}`)).toHaveAttribute('tabindex', '-1');
}
});
it('ArrowRight moves font selection and focus to the next tile', () => {
render(<AppearancePanel />);
const first = screen.getByTestId(`appearance-font-${fontIds[0]}`);
first.focus();
fireEvent.keyDown(first, { key: 'ArrowRight' });
expect(useAppStore.getState().font).toBe(fontIds[1]);
const second = screen.getByTestId(`appearance-font-${fontIds[1]}`);
expect(second).toHaveFocus();
expect(second).toHaveAttribute('aria-checked', 'true');
// Roving tabindex followed the selection.
expect(second).toHaveAttribute('tabindex', '0');
expect(first).toHaveAttribute('tabindex', '-1');
});
it('ArrowLeft wraps from the first font to the last', () => {
render(<AppearancePanel />);
const first = screen.getByTestId(`appearance-font-${fontIds[0]}`);
first.focus();
fireEvent.keyDown(first, { key: 'ArrowLeft' });
expect(useAppStore.getState().font).toBe(fontIds[fontIds.length - 1]);
});
it('arrow keys move the theme-dot selection too', () => {
render(<AppearancePanel />);
const gruvbox = screen.getByRole('radio', { name: 'Gruvbox' });
gruvbox.focus();
fireEvent.keyDown(gruvbox, { key: 'ArrowDown' });
expect(useAppStore.getState().theme).toBe('midnight');
expect(screen.getByRole('radio', { name: 'Midnight' })).toHaveFocus();
});
});
describe('AppearancePanel — auto-play preview toggle (#666)', () => {
it('defaults to ON (preserves existing auto-play behavior)', () => {
expect(useAppStore.getState().autoPlayPreview).toBe(true);
@@ -27,7 +27,12 @@ export default function AsrOpenAICompatPanel() {
const [apiKey, setApiKey] = useState('');
const [hasKey, setHasKey] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
// Last server-acknowledged values: the one Save button persists all three
// fields, so it stays disabled until something actually differs (dirty) and
// a successful save shows an explicit "Saved" confirmation.
const [server, setServer] = useState({ base_url: '', model: '' });
const refresh = useCallback(async () => {
setError(null);
@@ -37,6 +42,7 @@ export default function AsrOpenAICompatPanel() {
setModel(d?.model || '');
setHasKey(Boolean(d?.has_key));
setApiKey(''); // the key is never returned the field always starts blank
setServer({ base_url: d?.base_url || '', model: d?.model || '' });
} catch (e) {
setError(e?.message || t('models.asrOpenAICompatLoadError'));
}
@@ -68,6 +74,8 @@ export default function AsrOpenAICompatPanel() {
setModel(d.model || '');
setHasKey(Boolean(d.has_key));
setApiKey('');
setServer({ base_url: d.base_url || '', model: d.model || '' });
setSaved(true);
} catch (e) {
setError(e?.message || t('models.asrOpenAICompatSaveError'));
} finally {
@@ -75,6 +83,8 @@ export default function AsrOpenAICompatPanel() {
}
};
const dirty = baseUrl !== server.base_url || model !== server.model || apiKey !== '';
return (
<SettingsSection
icon={Mic}
@@ -139,11 +149,20 @@ export default function AsrOpenAICompatPanel() {
size="sm"
onClick={save}
loading={saving}
disabled={saving}
disabled={saving || !dirty}
data-testid="asr-openai-compat-save"
>
{t('common.save')}
</Button>
{saved && !dirty && !saving && (
<span
className="text-[length:var(--text-xs)] text-[color:var(--chrome-fg-dim)]"
role="status"
data-testid="asr-openai-compat-saved"
>
{t('models.asrOpenAICompatSaved')}
</span>
)}
</>
}
/>
@@ -0,0 +1,397 @@
/**
* Settings Audio tools the power-user surface for the media tools most
* users never see (the wizard + backend provision them invisibly).
*
* One row per tool:
* FFmpeg / FFprobe version + origin badge (Bundled / System / Custom /
* App package) + path; actions: Use system copy (auto-detect),
* Choose file (picker in Tauri, inline path input everywhere),
* Restore bundled (always-safe revert). The section header carries
* "Update bundled build" (one download covers both binaries).
* yt-dlp module version + Update (fetches the newest wheel into an
* update-surviving overlay; applies on restart) + Restore tested version.
*
* Absorbs the FFmpeg-path override that used to live in Settings Network
* same backend store (prefs `env.FFMPEG_PATH`), one control surface.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { AudioLines, Film, ScanSearch, DownloadCloud } from 'lucide-react';
import { Button, Badge } from '../../ui';
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
import RestartBadge from './RestartBadge';
import { isTauri } from './native';
const ORIGIN_TONE = {
bundled: 'success',
sidecar: 'success',
system: 'info',
custom: 'warn',
};
function OriginBadge({ origin }) {
const { t } = useTranslation();
if (!origin) return null;
const labels = {
bundled: t('settings.audio_tools_origin_bundled', { defaultValue: 'Bundled' }),
system: t('settings.audio_tools_origin_system', { defaultValue: 'System' }),
custom: t('settings.audio_tools_origin_custom', { defaultValue: 'Custom' }),
sidecar: t('settings.audio_tools_origin_sidecar', { defaultValue: 'App package' }),
};
return (
<Badge tone={ORIGIN_TONE[origin] || 'neutral'} size="xs" data-testid={`origin-${origin}`}>
{labels[origin] || origin}
</Badge>
);
}
/** Open the OS file picker in Tauri; return the chosen path or null. */
async function pickBinary(title) {
if (!isTauri()) return null;
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const picked = await open({ multiple: false, directory: false, title });
return typeof picked === 'string' ? picked : null;
} catch {
return null;
}
}
function BinaryRow({ tool, info, onAction, busy }) {
const { t } = useTranslation();
const [path, setPath] = useState('');
const [showInput, setShowInput] = useState(false);
const label = tool === 'ffmpeg' ? 'FFmpeg' : 'FFprobe';
const chooseFile = async () => {
const picked = await pickBinary(label);
if (picked) {
onAction(`/media-tools/${tool}/custom-path`, { path: picked });
} else {
// Web preview / picker unavailable fall back to the inline input.
setShowInput(true);
}
};
return (
<SettingRow
align="start"
stack
icon={tool === 'ffmpeg' ? Film : ScanSearch}
title={
<>
{label}
<OriginBadge origin={info?.origin} />
{!info?.ok && (
<Badge tone="warn" size="xs">
{t('settings.audio_tools_not_found', { defaultValue: 'Not available' })}
</Badge>
)}
</>
}
note={
info?.ok ? (
<>
{info.version ||
t('settings.audio_tools_version_unknown', { defaultValue: 'version unknown' })}
{' — '}
<code className="font-mono">{info.path}</code>
</>
) : (
t(`settings.audio_tools_${tool}_desc`)
)
}
control={
<>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onAction(`/media-tools/${tool}/use-system`)}
aria-label={`${label}: ${t('settings.audio_tools_use_system')}`}
>
{t('settings.audio_tools_use_system', { defaultValue: 'Use system copy' })}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={chooseFile}
aria-label={`${label}: ${t('settings.audio_tools_choose_file')}`}
>
{t('settings.audio_tools_choose_file', { defaultValue: 'Choose file…' })}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onAction(`/media-tools/${tool}/restore`)}
aria-label={`${label}: ${t('settings.audio_tools_restore')}`}
>
{t('settings.audio_tools_restore', { defaultValue: 'Restore bundled' })}
</Button>
{showInput && (
<>
<SettingsInput
placeholder={tool === 'ffmpeg' ? '/usr/bin/ffmpeg' : '/usr/bin/ffprobe'}
value={path}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' &&
path.trim() &&
onAction(`/media-tools/${tool}/custom-path`, { path: path.trim() })
}
aria-label={t('settings.audio_tools_path_input_aria', {
tool: label,
defaultValue: '{{tool}} binary path',
})}
/>
<Button
size="sm"
variant="subtle"
disabled={busy || !path.trim()}
onClick={() => onAction(`/media-tools/${tool}/custom-path`, { path: path.trim() })}
>
{t('credentials.save', { defaultValue: 'Save' })}
</Button>
</>
)}
</>
}
/>
);
}
export default function AudioToolsPanel() {
const { t } = useTranslation();
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
const acquireWasRunning = useRef(false);
const ytdlpWasRunning = useRef(false);
const load = useCallback(async () => {
try {
const { apiJson } = await import('../../api/client');
const st = await apiJson('/media-tools/status');
setStatus(st);
return st;
} catch {
return null;
}
}, []);
useEffect(() => {
load();
}, [load]);
// Poll while a background op runs; toast exactly once on the edge.
const acquire = status?.ops?.acquire;
const ytdlpOp = status?.ops?.ytdlp_update;
useEffect(() => {
if (acquire?.state === 'running') acquireWasRunning.current = true;
else if (acquireWasRunning.current) {
acquireWasRunning.current = false;
if (acquire?.state === 'done') {
toast.success(
t('settings.audio_tools_bundle_done', { defaultValue: 'Bundled media engine ready.' }),
);
} else if (acquire?.state === 'error') {
toast.error(
t('settings.audio_tools_bundle_failed', {
message: acquire.error,
defaultValue: 'Bundled download failed: {{message}}',
}),
);
}
}
if (ytdlpOp?.state === 'running') ytdlpWasRunning.current = true;
else if (ytdlpWasRunning.current) {
ytdlpWasRunning.current = false;
if (ytdlpOp?.state === 'done') {
toast.success(
t('settings.audio_tools_ytdlp_updated', {
version: ytdlpOp.version,
defaultValue: 'yt-dlp {{version}} installed — restart the backend to apply.',
}),
);
} else if (ytdlpOp?.state === 'error') {
toast.error(
t('settings.audio_tools_ytdlp_update_failed', {
message: ytdlpOp.error,
defaultValue: 'yt-dlp update failed: {{message}}',
}),
);
}
}
if (acquire?.state !== 'running' && ytdlpOp?.state !== 'running') return undefined;
const iv = setInterval(load, 1500);
return () => clearInterval(iv);
}, [acquire?.state, ytdlpOp?.state, load, t, acquire?.error, ytdlpOp?.error, ytdlpOp?.version]);
const post = useCallback(
async (path, body) => {
setBusy(true);
try {
const { apiFetch } = await import('../../api/client');
const res = await apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let detail = `HTTP ${res.status}`;
try {
detail = (await res.json())?.detail || detail;
} catch {
/* non-JSON error body */
}
throw new Error(detail);
}
return true;
} catch (e) {
toast.error(
t('settings.audio_tools_path_failed', {
message: e.message,
defaultValue: "Couldn't set path: {{message}}",
}),
);
return false;
} finally {
setBusy(false);
load();
}
},
[load, t],
);
const onToolAction = useCallback(
async (path, body) => {
const ok = await post(path, body);
if (ok && (path.endsWith('/custom-path') || path.endsWith('/use-system'))) {
toast.success(
t('settings.audio_tools_path_set', {
tool: path.includes('ffprobe') ? 'FFprobe' : 'FFmpeg',
path: body?.path || t('settings.audio_tools_origin_system', { defaultValue: 'System' }),
defaultValue: '{{tool}} now uses {{path}}',
}),
);
} else if (ok && path.endsWith('/restore')) {
toast.success(
t('settings.audio_tools_restored', {
tool: path.includes('ffprobe') ? 'FFprobe' : 'FFmpeg',
defaultValue: '{{tool}} restored to the app-managed build.',
}),
);
}
},
[post, t],
);
const ytdlp = status?.tools?.ytdlp;
const ytdlpNeedsRestart =
ytdlpOp?.state === 'done' ||
(ytdlp?.overlay_version && ytdlp.overlay_version !== ytdlp.version);
return (
<SettingsSection
icon={AudioLines}
title={t('settings.audio_tools', { defaultValue: 'Audio tools' })}
description={t('settings.audio_tools_desc', {
defaultValue:
'The media engine (FFmpeg, FFprobe) and video downloader (yt-dlp) the app manages for you.',
})}
actions={
<Button
size="sm"
variant="ghost"
leading={<DownloadCloud size={12} />}
loading={acquire?.state === 'running'}
disabled={busy || acquire?.state === 'running'}
onClick={() => post('/media-tools/acquire')}
aria-label={t('settings.audio_tools_update_bundle', {
defaultValue: 'Update bundled build',
})}
>
{acquire?.state === 'running'
? t('settings.audio_tools_bundle_updating', {
percent: Math.round((acquire.progress || 0) * 100),
defaultValue: 'Downloading bundled build… {{percent}}%',
})
: t('settings.audio_tools_update_bundle', { defaultValue: 'Update bundled build' })}
</Button>
}
>
<BinaryRow tool="ffmpeg" info={status?.tools?.ffmpeg} onAction={onToolAction} busy={busy} />
<BinaryRow tool="ffprobe" info={status?.tools?.ffprobe} onAction={onToolAction} busy={busy} />
<SettingRow
align="start"
stack
icon={DownloadCloud}
title={
<>
{t('settings.audio_tools_ytdlp', { defaultValue: 'yt-dlp (video downloader)' })}
{ytdlp?.origin && (
<OriginBadge origin={ytdlp.origin === 'custom' ? 'custom' : 'bundled'} />
)}
{ytdlpNeedsRestart && <RestartBadge />}
</>
}
note={
<>
{ytdlp?.version ||
t('settings.audio_tools_version_unknown', { defaultValue: 'version unknown' })}
{' — '}
{t('settings.audio_tools_ytdlp_desc', {
defaultValue:
'Powers video/clip imports. Site support changes faster than app releases — update it here when imports start failing.',
})}
</>
}
hint={t('settings.audio_tools_manual_hint', {
defaultValue:
'Prefer your package manager? Install FFmpeg yourself (macOS: brew install ffmpeg · Debian/Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg) and press Use system copy. Nothing is ever installed system-wide by the app.',
})}
control={
<>
<Button
size="sm"
variant="subtle"
loading={ytdlpOp?.state === 'running'}
disabled={busy || ytdlpOp?.state === 'running'}
onClick={() => post('/media-tools/ytdlp/update')}
aria-label={`yt-dlp: ${t('settings.audio_tools_ytdlp_update', { defaultValue: 'Update' })}`}
>
{t('settings.audio_tools_ytdlp_update', { defaultValue: 'Update' })}
</Button>
{(ytdlp?.origin === 'custom' || ytdlp?.overlay_version) && (
<Button
size="sm"
variant="ghost"
disabled={busy || ytdlpOp?.state === 'running'}
onClick={async () => {
const ok = await post('/media-tools/ytdlp/restore');
if (ok) {
toast.success(
t('settings.audio_tools_ytdlp_restored', {
defaultValue: 'Tested yt-dlp restored — restart the backend to apply.',
}),
);
}
}}
aria-label={`yt-dlp: ${t('settings.audio_tools_ytdlp_restore', { defaultValue: 'Restore tested version' })}`}
data-testid="ytdlp-restore"
>
{t('settings.audio_tools_ytdlp_restore', {
defaultValue: 'Restore tested version',
})}
{ytdlp?.baseline_version ? ` (${ytdlp.baseline_version})` : ''}
</Button>
)}
</>
}
/>
</SettingsSection>
);
}
@@ -0,0 +1,155 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('react-hot-toast', () => ({
default: { error: vi.fn(), success: vi.fn() },
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('../../api/client', () => ({
apiJson: vi.fn(),
apiFetch: vi.fn(),
}));
import { toast } from 'react-hot-toast';
import { apiJson, apiFetch } from '../../api/client';
import AudioToolsPanel from './AudioToolsPanel';
const STATUS = {
ready: true,
platform_key: 'darwin_arm64',
tools: {
ffmpeg: {
tool: 'ffmpeg',
ok: true,
path: '/data/media_tools/ffbin-abc/darwin_arm64/ffmpeg',
version: '7.0',
origin: 'bundled',
},
ffprobe: {
tool: 'ffprobe',
ok: true,
path: '/opt/homebrew/bin/ffprobe',
version: '8.1.1',
origin: 'system',
},
ytdlp: {
tool: 'yt-dlp',
ok: true,
path: '/venv/site-packages/yt_dlp',
version: '2026.06.09',
origin: 'bundled',
overlay_version: null,
baseline_version: null,
},
},
ops: {
acquire: { state: 'idle', progress: 0, error: null },
ytdlp_update: { state: 'idle', progress: 0, error: null, version: null },
},
};
const okResponse = { ok: true, json: async () => ({}) };
describe('AudioToolsPanel — power-user surface for the media tools', () => {
beforeEach(() => {
vi.clearAllMocks();
apiJson.mockResolvedValue(JSON.parse(JSON.stringify(STATUS)));
apiFetch.mockResolvedValue(okResponse);
});
it('renders one row per tool with version, path, and origin badge', async () => {
render(<AudioToolsPanel />);
await waitFor(() => expect(apiJson).toHaveBeenCalledWith('/media-tools/status'));
expect(await screen.findByText('FFmpeg')).toBeInTheDocument();
expect(screen.getByText('FFprobe')).toBeInTheDocument();
expect(screen.getByText('yt-dlp (video downloader)')).toBeInTheDocument();
// ffmpeg + yt-dlp are both app-managed here; ffprobe is a system copy.
const bundled = screen.getAllByTestId('origin-bundled');
expect(bundled).toHaveLength(2);
expect(bundled[0]).toHaveTextContent('Bundled');
expect(screen.getByTestId('origin-system')).toHaveTextContent('System');
expect(screen.getByText('/opt/homebrew/bin/ffprobe')).toBeInTheDocument();
expect(screen.getByText(/2026\.06\.09/)).toBeInTheDocument();
});
it('Use system copy posts the endpoint and toasts success', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFmpeg: Use system copy'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffmpeg/use-system', expect.anything()),
);
await waitFor(() => expect(toast.success).toHaveBeenCalled());
});
it('Restore bundled is per-tool and always available (safe revert)', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFprobe: Restore bundled'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffprobe/restore', expect.anything()),
);
await waitFor(() => expect(toast.success).toHaveBeenCalled());
});
it('surfaces the backend error detail on a failed action', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ detail: 'That file exists but does not run as a media tool' }),
});
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFmpeg: Use system copy'));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(String(toast.error.mock.calls[0][0])).toContain('does not run as a media tool');
});
it('yt-dlp row: Update posts the update endpoint', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('yt-dlp: Update'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ytdlp/update', expect.anything()),
);
});
it('yt-dlp row: Restore tested version appears only when an overlay is active', async () => {
const { unmount } = render(<AudioToolsPanel />);
await screen.findByText('yt-dlp (video downloader)');
expect(screen.queryByTestId('ytdlp-restore')).not.toBeInTheDocument();
unmount();
const overlaid = JSON.parse(JSON.stringify(STATUS));
overlaid.tools.ytdlp.origin = 'custom';
overlaid.tools.ytdlp.overlay_version = '2026.07.01';
overlaid.tools.ytdlp.version = '2026.07.01';
overlaid.tools.ytdlp.baseline_version = '2026.06.09';
apiJson.mockResolvedValue(overlaid);
render(<AudioToolsPanel />);
const restore = await screen.findByTestId('ytdlp-restore');
expect(restore).toHaveTextContent('Restore tested version (2026.06.09)');
fireEvent.click(restore);
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ytdlp/restore', expect.anything()),
);
});
it('section header offers Update bundled build (one download covers both binaries)', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('Update bundled build'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/acquire', expect.anything()),
);
});
it('package-manager commands are copy-only prose, never buttons', async () => {
render(<AudioToolsPanel />);
await screen.findByText('FFmpeg');
// The InfoHint copy mentions brew/apt as a secondary affordance, but no
// button/control runs a package manager.
const buttons = screen.getAllByRole('button').map((b) => b.textContent || '');
expect(buttons.join(' ')).not.toMatch(/brew|apt|winget|choco/i);
});
});
+19 -35
View File
@@ -1,19 +1,26 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { addBreadcrumb } from '../../utils/breadcrumbs';
import { listEngines, selectEngine } from '../../api/engines';
import { selectEngine } from '../../api/engines';
import { notifyEngineSelected } from '../../utils/engineSelectToast';
import EngineCompatibilityMatrix from '../EngineCompatibilityMatrix';
import { SETTINGS_SECTION_SURFACE } from './primitives';
/** One pinned matrix per family, stacked in this order. ASR used to be
* reachable only through the matrix's family tabs, which read as a
* TTS-only table README even promised a Settings ASR picker that
* didn't exist (UX gap found during #877). Every family now gets a
* visible picker; `OMNIVOICE_*_BACKEND` env vars still win over any pick. */
const FAMILIES = ['tts', 'asr', 'llm'];
/** Settings → Engines: ONE section, one matrix, a TTS / ASR / LLM tab strip.
*
* The page used to stack three pinned per-family matrices; with every row
* free to grow (wrapping names, stacked badges, inline failure prose) a
* single engine could fill a viewport and the ASR/LLM pickers lived below
* the fold. The matrix's family tab strip (Radix Segmented roving
* tabindex + arrow keys, active engine named in each tab caption) now
* presents one family at a time instead, over compact fixed-height rows.
*
* Data contract is unchanged: the single mounted matrix issues exactly one
* GET /engines + one GET /model/loaded per Settings open (switching tabs
* re-slices the same payload no refetch), `openSettingsTab('engines')`
* still lands here, and `OMNIVOICE_*_BACKEND` env vars still win over any
* pick made in the UI. */
export default function EnginesTab() {
const { t } = useTranslation();
@@ -40,32 +47,9 @@ export default function EnginesTab() {
[t],
);
// The stacked matrices all consume the same GET /engines payload share
// one in-flight request so opening the tab probes every engine once, not
// once per family. A per-matrix Refresh after the shared promise settles
// still triggers a fresh fetch.
const inflightList = useRef(null);
const listEnginesShared = useCallback(() => {
if (!inflightList.current) {
inflightList.current = listEngines().finally(() => {
inflightList.current = null;
});
}
return inflightList.current;
}, []);
return (
<>
{FAMILIES.map((family) => (
<section key={family} className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
<EngineCompatibilityMatrix
family={family}
showFamilyTabs={false}
onSelect={onSelect}
apiListEngines={listEnginesShared}
/>
</section>
))}
</>
<section className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
<EngineCompatibilityMatrix family="tts" onSelect={onSelect} />
</section>
);
}
@@ -15,7 +15,15 @@ vi.mock('../../api/engines', () => ({
selfTestEngine: vi.fn(),
}));
// Residency layer (/model/loaded) mocked so the matrix never hits the
// network in tests; the single-probe behavior is asserted below.
vi.mock('../../api/system', () => ({
listLoadedModels: vi.fn(),
unloadLoadedModel: vi.fn(),
}));
import { listEngines, selectEngine } from '../../api/engines';
import { listLoadedModels } from '../../api/system';
import EnginesTab from './EnginesTab';
function entry(id, name) {
@@ -43,31 +51,60 @@ const ENGINES = {
llm: { active: 'off', backends: [entry('off', 'Off (test)')] },
};
/** Click the family tab whose label text is `label` (TTS / ASR / LLM). */
function clickFamilyTab(label) {
const tab = Array.from(document.querySelectorAll('.engine-matrix__tab-family')).find(
(el) => el.textContent === label,
);
expect(tab).toBeTruthy();
fireEvent.click(tab.closest('button'));
}
describe('EnginesTab', () => {
beforeEach(() => {
vi.clearAllMocks();
listEngines.mockResolvedValue(ENGINES);
listLoadedModels.mockResolvedValue({ models: [], count: 0 });
});
it('renders a pinned picker per family — TTS, ASR and LLM all visible at once', async () => {
it('renders ONE tabbed section — TTS/ASR/LLM tab strip, one family at a time', async () => {
render(<EnginesTab />);
await waitFor(() => screen.getByText('WhisperX (test)'));
await waitFor(() => screen.getByText('OmniVoice (test)'));
// One named section per family (the ASR picker used to be tucked behind
// a family tab inside a single TTS-titled matrix no picker to find).
expect(screen.getByText('TTS Engines')).toBeInTheDocument();
expect(screen.getByText('ASR Engines')).toBeInTheDocument();
expect(screen.getByText('LLM Engines')).toBeInTheDocument();
// Pinned matrices render no family switcher.
expect(document.querySelector('.engine-matrix__tab-family')).toBeNull();
// One settings card, not three stacked per-family matrices.
expect(document.querySelectorAll('[data-slot="settings-section"]').length).toBe(1);
// The tab strip offers all three families (with the active engine caption).
expect(document.querySelectorAll('.engine-matrix__tab-family').length).toBe(3);
// Only the selected family's engines are on screen.
expect(screen.queryByText('WhisperX (test)')).not.toBeInTheDocument();
expect(screen.queryByText('Off (test)')).not.toBeInTheDocument();
});
it('the stacked matrices share one GET /engines on mount', async () => {
it('switching to the ASR tab shows ASR engines without refetching /engines', async () => {
render(<EnginesTab />);
await waitFor(() => screen.getByText('OmniVoice (test)'));
clickFamilyTab('ASR');
await waitFor(() => screen.getByText('WhisperX (test)'));
expect(screen.getByText('OpenAI-compatible ASR (test)')).toBeInTheDocument();
expect(screen.queryByText('OmniVoice (test)')).not.toBeInTheDocument();
// Tab switches re-slice the already-fetched payload no second request.
expect(listEngines).toHaveBeenCalledTimes(1);
});
it('fetches GET /engines exactly once on mount', async () => {
render(<EnginesTab />);
await waitFor(() => screen.getByText('OmniVoice (test)'));
expect(listEngines).toHaveBeenCalledTimes(1);
});
it('probes GET /model/loaded exactly once on mount', async () => {
render(<EnginesTab />);
await waitFor(() => screen.getByText('OmniVoice (test)'));
await waitFor(() => expect(listLoadedModels).toHaveBeenCalled());
expect(listLoadedModels).toHaveBeenCalledTimes(1);
});
it('clicking Use on an ASR engine selects it with family="asr"', async () => {
selectEngine.mockResolvedValue({
family: 'asr',
@@ -78,6 +115,9 @@ describe('EnginesTab', () => {
routing_reason: null,
});
render(<EnginesTab />);
await waitFor(() => screen.getByText('OmniVoice (test)'));
clickFamilyTab('ASR');
await waitFor(() => screen.getByText('OpenAI-compatible ASR (test)'));
fireEvent.click(screen.getByRole('button', { name: /use openai-compatible asr \(test\)/i }));
@@ -56,8 +56,14 @@ export default function GeneralTab() {
value={reviewMode}
onChange={setReviewMode}
items={[
{ value: 'on', label: t('engines.review_on') },
{ value: 'off', label: t('engines.review_off') },
{
value: 'on',
label: t('settings.review_mode_on', { defaultValue: 'Pause for review' }),
},
{
value: 'off',
label: t('settings.review_mode_off', { defaultValue: 'Run straight through' }),
},
]}
/>
}
@@ -10,13 +10,17 @@
* PUT /api/settings/hf-mirror body {url} (empty url clears official)
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Globe } from 'lucide-react';
import { Globe, RefreshCw } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { apiJson, apiFetch } from '../../api/client';
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
import { Button } from '../../ui';
import RestartBadge from './RestartBadge';
/** Normalize a mirror URL for equality checks (trailing slashes, whitespace). */
const normalizeMirror = (u) => (u || '').trim().replace(/\/+$/, '');
export default function HFMirrorPanel() {
const { t } = useTranslation();
const [state, setState] = useState(null);
@@ -52,6 +56,7 @@ export default function HFMirrorPanel() {
const d = await res.json();
setUrl(d.configured || '');
setRestart(Boolean(d.restart_required));
toast.success(t('models.mirror_saved', { defaultValue: 'Mirror setting saved' }));
refresh();
} catch (e) {
setError(e?.message || t('models.mirror_save_error'));
@@ -60,8 +65,10 @@ export default function HFMirrorPanel() {
}
};
if (!state) return null;
const configured = normalizeMirror(state?.configured);
// Always render the section shell: a restricted-network user whose backend
// GET failed is exactly the user who needs this panel never let it vanish.
return (
<SettingsSection
icon={Globe}
@@ -75,54 +82,84 @@ export default function HFMirrorPanel() {
</div>
)}
<SettingRow
stack
title={t('models.mirror_preset_title')}
hint={t('models.mirror_preset_hint')}
control={
<div className="flex flex-wrap items-center gap-[6px] min-w-0 max-w-full">
{state.presets.map((p) => (
<Button
variant="preset"
key={p.label}
onClick={() => save(p.url)}
disabled={saving}
data-testid={`hf-preset-${p.url || 'official'}`}
>
{p.label}
</Button>
))}
</div>
}
/>
{!state && !error && (
<div
data-testid="hf-mirror-loading"
className="py-[var(--space-4)] text-[color:var(--chrome-fg-muted)] text-[length:var(--text-sm)]"
>
{t('common.loading')}
</div>
)}
<SettingRow
stack
title="HF_ENDPOINT"
subtitle={restart ? t('models.mirror_restart_note') : undefined}
control={
<>
<SettingsInput
mono
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://hf-mirror.com"
data-testid="hf-mirror-url"
/>
<Button
variant="subtle"
size="sm"
onClick={() => save(url)}
loading={saving}
disabled={saving}
data-testid="hf-mirror-save"
>
{t('common.save')}
</Button>
</>
}
/>
{!state && error && (
<Button
variant="subtle"
size="sm"
leading={<RefreshCw size={13} aria-hidden="true" />}
onClick={refresh}
data-testid="hf-mirror-retry"
>
{t('models.mirror_retry', { defaultValue: 'Retry' })}
</Button>
)}
{state && (
<>
<SettingRow
stack
title={t('models.mirror_preset_title')}
hint={t('models.mirror_preset_hint')}
control={
<div className="flex flex-wrap items-center gap-[6px] min-w-0 max-w-full">
{state.presets.map((p) => (
<Button
variant="preset"
key={p.label}
active={normalizeMirror(p.url) === configured}
onClick={() => save(p.url)}
disabled={saving}
data-testid={`hf-preset-${p.url || 'official'}`}
>
{p.label}
</Button>
))}
</div>
}
/>
<SettingRow
stack
title={t('models.mirror_custom_url', { defaultValue: 'Custom mirror URL' })}
note={t('models.mirror_custom_url_note', {
defaultValue: 'Sets the HF_ENDPOINT environment variable for Hugging Face downloads.',
})}
subtitle={restart ? t('models.mirror_restart_note') : undefined}
control={
<>
<SettingsInput
mono
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://hf-mirror.com"
aria-label={t('models.mirror_custom_url', { defaultValue: 'Custom mirror URL' })}
data-testid="hf-mirror-url"
/>
<Button
variant="subtle"
size="sm"
onClick={() => save(url)}
loading={saving}
disabled={saving}
data-testid="hf-mirror-save"
>
{t('common.save')}
</Button>
</>
}
/>
</>
)}
</SettingsSection>
);
}
@@ -0,0 +1,89 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('react-hot-toast', () => ({
default: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('../../api/client', () => ({
apiJson: vi.fn(),
apiFetch: vi.fn(),
}));
import toast from 'react-hot-toast';
import { apiJson, apiFetch } from '../../api/client';
import HFMirrorPanel from './HFMirrorPanel';
const STATE = {
configured: 'https://hf-mirror.com',
effective: 'https://hf-mirror.com',
presets: [
{ label: 'Official (huggingface.co)', url: '' },
{ label: 'hf-mirror.com (community, China)', url: 'https://hf-mirror.com' },
],
};
describe('HFMirrorPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('keeps the panel visible with an error and a Retry when the initial GET fails', async () => {
// The restricted-network user whose backend GET 500s is exactly the user
// who needs this panel it must never silently vanish.
apiJson.mockRejectedValueOnce(new Error('HTTP 500'));
render(<HFMirrorPanel />);
expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 500');
expect(screen.getByText('Hugging Face mirror')).toBeInTheDocument();
// Retry re-fetches and renders the rows.
apiJson.mockResolvedValueOnce(STATE);
fireEvent.click(screen.getByTestId('hf-mirror-retry'));
expect(await screen.findByTestId('hf-mirror-url')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows a loading state while the GET is in flight (never an empty gap)', () => {
apiJson.mockReturnValue(new Promise(() => {}));
render(<HFMirrorPanel />);
expect(screen.getByText('Hugging Face mirror')).toBeInTheDocument();
expect(screen.getByTestId('hf-mirror-loading')).toBeInTheDocument();
});
it('marks the configured preset as active', async () => {
apiJson.mockResolvedValue(STATE);
render(<HFMirrorPanel />);
const mirror = await screen.findByTestId('hf-preset-https://hf-mirror.com');
const official = screen.getByTestId('hf-preset-official');
expect(mirror).toHaveAttribute('aria-pressed', 'true');
expect(official).toHaveAttribute('aria-pressed', 'false');
});
it('labels the custom-URL row in plain language and toasts on save', async () => {
apiJson.mockResolvedValue(STATE);
apiFetch.mockResolvedValue({
json: async () => ({ configured: 'https://mirror.example', restart_required: true }),
});
render(<HFMirrorPanel />);
// Plain translated label (HF_ENDPOINT is a subtitle detail, not the title),
// and the input carries an accessible name.
const input = await screen.findByLabelText('Custom mirror URL');
expect(screen.getByText('Custom mirror URL')).toBeInTheDocument();
fireEvent.change(input, { target: { value: 'https://mirror.example' } });
fireEvent.click(screen.getByTestId('hf-mirror-save'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Mirror setting saved'));
expect(apiFetch).toHaveBeenCalledWith(
'/api/settings/hf-mirror',
expect.objectContaining({ method: 'PUT' }),
);
});
});
@@ -15,6 +15,7 @@ import { History } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { apiJson, apiFetch } from '../../api/client';
import { Button } from '../../ui';
import { SettingsSection, SettingRow, InfoHint } from './primitives';
export default function HistoryRetentionPanel() {
@@ -22,20 +23,36 @@ export default function HistoryRetentionPanel() {
const [cap, setCap] = useState('');
const [def, setDef] = useState(200);
const [loading, setLoading] = useState(true);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const d = await apiJson('/api/settings/history-retention');
setCap(String(d?.cap ?? ''));
if (Number.isInteger(d?.default)) setDef(d.default);
setLoaded(true);
} catch (e) {
// Backend older than this panel leave the default hint in place.
if (e?.status === 404) {
// Backend older than this panel leave the default hint in place.
setLoaded(true);
} else {
// Transport failure / 500: the shown default may not be the real cap,
// so say so and hold Save until a load succeeds.
setError(
e?.message ||
t('settings.history_retention_load_failed', {
defaultValue: 'Could not load the current retention limit',
}),
);
}
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
refresh();
@@ -49,15 +66,12 @@ export default function HistoryRetentionPanel() {
}
setSaving(true);
try {
// apiFetch throws ApiError on any non-OK response.
const res = await apiFetch('/api/settings/history-retention', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cap: n }),
});
if (!res.ok) {
const b = await res.json().catch(() => ({}));
throw new Error(b?.detail || `HTTP ${res.status}`);
}
const b = await res.json();
setCap(String(b?.cap ?? n));
toast.success(
@@ -89,6 +103,11 @@ export default function HistoryRetentionPanel() {
</InfoHint>
}
>
{error && (
<div className="perfpanel__error" role="alert">
{error}
</div>
)}
<SettingRow
title={t('settings.history_retention_cap', { defaultValue: 'Takes to keep' })}
subtitle={t('settings.history_retention_cap_hint', {
@@ -105,20 +124,28 @@ export default function HistoryRetentionPanel() {
value={cap}
placeholder={String(def)}
onChange={(e) => setCap(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !saving && !loading && loaded) {
e.preventDefault();
save();
}
}}
disabled={saving || loading}
aria-label={t('settings.history_retention_cap', { defaultValue: 'Takes to keep' })}
data-testid="history-retention-input"
/>
<button
className="flex-none cursor-pointer rounded-[var(--chrome-radius-pill)] [border:1px_solid_transparent] bg-[var(--chrome-accent)] px-[var(--space-4)] py-[var(--space-2)] font-sans text-[length:var(--text-base)] text-[var(--chrome-bg)] disabled:cursor-default disabled:opacity-50"
<Button
variant="primary"
size="sm"
onClick={save}
disabled={saving || loading}
loading={saving}
disabled={loading || !loaded}
data-testid="history-retention-save"
>
{saving
? t('common.saving', { defaultValue: 'Saving…' })
: t('common.save', { defaultValue: 'Save' })}
</button>
</Button>
</div>
}
/>
@@ -50,6 +50,43 @@ describe('HistoryRetentionPanel', () => {
});
});
it('saves on Enter in the input', async () => {
const fetchMock = mockFetchSequence(
{ status: 200, body: { cap: 200, default: 200 } }, // initial GET
{ status: 200, body: { cap: 75, default: 200 } }, // PUT
);
global.fetch = fetchMock;
render(<HistoryRetentionPanel />);
await waitFor(() => screen.getByTestId('history-retention-input'));
const input = screen.getByTestId('history-retention-input');
fireEvent.change(input, { target: { value: '75' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
const put = fetchMock.mock.calls.find(([_u, opts]) => opts && opts.method === 'PUT');
expect(put).toBeTruthy();
expect(JSON.parse(put[1].body)).toEqual({ cap: 75 });
});
});
it('surfaces a load failure (500) and holds Save until a load succeeds', async () => {
global.fetch = mockFetchSequence({ status: 500, body: { detail: 'db locked' } });
render(<HistoryRetentionPanel />);
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
expect(screen.getByRole('alert')).toHaveTextContent(/db locked/);
expect(screen.getByTestId('history-retention-save')).toBeDisabled();
});
it('stays silent and usable on a 404 (backend older than the panel)', async () => {
global.fetch = mockFetchSequence({ status: 404, body: { detail: 'Not Found' } });
render(<HistoryRetentionPanel />);
await waitFor(() => expect(screen.getByTestId('history-retention-save')).not.toBeDisabled());
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// The hardcoded default hint stays in place.
expect(screen.getByTestId('history-retention-input')).toHaveAttribute('placeholder', '200');
});
it('rejects a negative cap client-side without a PUT', async () => {
const fetchMock = mockFetchSequence({ status: 200, body: { cap: 200, default: 200 } });
global.fetch = fetchMock;
+36 -5
View File
@@ -32,11 +32,21 @@ function keyEventToAccelerator(e) {
return [...mods, key].join('+');
}
// A pure modifier press means the user is still building the chord stay
// quiet. Anything else that fails to produce an accelerator (a bare letter,
// F5, Space) is a real rejection and deserves visible feedback.
function isPureModifierEvent(e) {
return /^(Meta|Control|Alt|Shift|OS)/.test(e.key || '');
}
export default function HotkeyTab() {
const { t } = useTranslation();
const [current, setCurrent] = useState('');
const [recording, setRecording] = useState(false);
const [pending, setPending] = useState('');
// True after a modifier-less press while recording drives the inline
// "add a modifier" feedback instead of listening forever in silence.
const [rejected, setRejected] = useState(false);
const [saving, setSaving] = useState(false);
const tauri = isTauri();
@@ -55,7 +65,9 @@ export default function HotkeyTab() {
}, [tauri]);
// While recording, swallow keystrokes globally and convert the next real
// press into an accelerator string. Escape cancels.
// press into an accelerator string. Escape cancels; losing window focus
// cancels too so a stray click outside doesn't leave a global
// key-swallowing listener armed forever.
useEffect(() => {
if (!recording) return;
const onKeyDown = (e) => {
@@ -64,16 +76,28 @@ export default function HotkeyTab() {
if (e.key === 'Escape') {
setRecording(false);
setPending('');
setRejected(false);
return;
}
const accel = keyEventToAccelerator(e);
if (accel) {
setPending(accel);
setRecording(false);
setRejected(false);
return;
}
if (!isPureModifierEvent(e)) setRejected(true);
};
const onBlur = () => {
setRecording(false);
setRejected(false);
};
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('blur', onBlur);
};
}, [recording]);
const save = async () => {
@@ -123,7 +147,11 @@ export default function HotkeyTab() {
<SettingRow
title={recording ? t('capture.press_key') : t('capture.new_shortcut')}
hint={<Trans i18nKey="capture.desc_detail" components={{ 1: <code />, 2: <code /> }} />}
control={recording ? t('capture.listening') : pending || '—'}
control={
recording
? (rejected && t('capture.needs_modifier')) || t('capture.listening')
: pending || '—'
}
mono
/>
@@ -132,13 +160,16 @@ export default function HotkeyTab() {
size="sm"
variant="subtle"
onClick={() => {
// Toggle: while recording, the same button cancels (Esc still
// works too) re-clicking must not silently re-arm the recorder.
setPending('');
setRecording(true);
setRejected(false);
setRecording(!recording);
}}
disabled={!tauri || saving}
leading={<Keyboard size={12} />}
>
{recording ? t('capture.recording') : t('capture.record_shortcut')}
{recording ? t('common.cancel') : t('capture.record_shortcut')}
</Button>
<Button
size="sm"
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import HotkeyTab from './HotkeyTab';
// Recording is only armed in the desktop shell; pretend we are in it and
// stub the two shortcut IPC commands.
vi.mock('./native', () => ({ isTauri: () => true }));
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(async (cmd) => (cmd === 'get_dictation_shortcut' ? 'CmdOrCtrl+Shift+Space' : '')),
}));
async function startRecording() {
render(<HotkeyTab />);
// Wait for the mount-time shortcut load so state updates stay inside act().
await screen.findByText('CmdOrCtrl+Shift+Space');
fireEvent.click(screen.getByRole('button', { name: 'Record shortcut' }));
expect(screen.getByText(/listening/)).toBeInTheDocument();
}
describe('HotkeyTab — recording feedback and cancel affordances', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('a modifier-less key press shows "add a modifier" feedback instead of silence', async () => {
await startRecording();
fireEvent.keyDown(window, { key: 'a', code: 'KeyA' });
expect(screen.getByText(/Add a modifier/)).toBeInTheDocument();
// Still recording the button stays in its cancel state.
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
it('a pure modifier press (chord in progress) does NOT trigger the rejection message', async () => {
await startRecording();
fireEvent.keyDown(window, { key: 'Control', code: 'ControlLeft', ctrlKey: true });
expect(screen.queryByText(/Add a modifier/)).toBeNull();
expect(screen.getByText(/listening/)).toBeInTheDocument();
});
it('a modifier+key press captures the accelerator and clears the rejection state', async () => {
await startRecording();
fireEvent.keyDown(window, { key: 'a', code: 'KeyA' }); // rejected first
fireEvent.keyDown(window, { key: 'a', code: 'KeyA', ctrlKey: true });
expect(screen.getByText('Ctrl+A')).toBeInTheDocument();
expect(screen.queryByText(/Add a modifier/)).toBeNull();
expect(screen.getByRole('button', { name: 'Record shortcut' })).toBeInTheDocument();
});
it('clicking the record button while recording cancels instead of re-arming', async () => {
await startRecording();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByText(/listening/)).toBeNull();
expect(screen.getByRole('button', { name: 'Record shortcut' })).toBeInTheDocument();
});
it('losing window focus cancels recording (no global key-swallower left armed)', async () => {
await startRecording();
fireEvent(window, new Event('blur'));
expect(screen.queryByText(/listening/)).toBeNull();
expect(screen.getByRole('button', { name: 'Record shortcut' })).toBeInTheDocument();
});
it('Escape cancels recording', async () => {
await startRecording();
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' });
expect(screen.queryByText(/listening/)).toBeNull();
});
});
@@ -115,8 +115,11 @@ export default function LLMProvidersPanel() {
populate(providers, id);
};
// Returns true when the PUT (and refresh) succeeded Test / Fetch models
// gate on it so they never probe the previously-stored config after a
// failed save (which could show a green "Test ok" beside a save error).
const save = async (makeActive) => {
if (!current) return;
if (!current) return false;
setSaving(true);
setError(null);
try {
@@ -138,8 +141,10 @@ export default function LLMProvidersPanel() {
// pins the choice the env banner already explains and the suggested
// button is disabled.
setSavedInactive(Boolean(data) && data.active !== current.id && !current.active_from_env);
return true;
} catch (e) {
setError(e?.message || t('settings.llmp_save_failed'));
return false;
} finally {
setSaving(false);
}
@@ -151,8 +156,10 @@ export default function LLMProvidersPanel() {
setTest(null);
setError(null);
try {
// Save first so the probe sees the just-typed key/URL.
await save(false);
// Save first so the probe sees the just-typed key/URL. If the save
// failed, stop: probing the stale stored config would contradict the
// save error with a misleading green badge.
if (!(await save(false))) return;
const res = await apiPost(`/api/settings/llm-providers/${current.id}/test`);
setTest(res);
} catch (e) {
@@ -167,8 +174,9 @@ export default function LLMProvidersPanel() {
setLoadingModels(true);
setError(null);
try {
// Save non-key fields first so the probe uses the just-typed base URL.
await save(false);
// Save non-key fields first so the probe uses the just-typed base URL;
// abort on a failed save (same stale-config trap as runTest).
if (!(await save(false))) return;
const res = await apiJson(`/api/settings/llm-providers/${current.id}/models`);
if (res.ok) {
setModels(res.models || []);
@@ -187,6 +195,8 @@ export default function LLMProvidersPanel() {
};
if (!providers.length) {
// A failed initial GET used to dead-end here (nothing re-runs refresh
// without a remount) the Retry button is the way back in.
return (
<SettingsSection
icon={Brain}
@@ -195,7 +205,15 @@ export default function LLMProvidersPanel() {
>
{error && (
<div className="perfpanel__error" role="alert">
{error}
<span className="mr-[8px]">{error}</span>
<Button
variant="subtle"
size="sm"
onClick={() => refresh()}
data-testid="llm-provider-retry"
>
{t('settings.retry', { defaultValue: 'Retry' })}
</Button>
</div>
)}
</SettingsSection>
@@ -200,6 +200,53 @@ describe('LLMProvidersPanel', () => {
expect(screen.getByText(/not yet used for translation/)).toBeInTheDocument();
});
it('a failed implicit save aborts Test — no probe against the stale stored config', async () => {
const fetchMock = mockFetchSequence(
{ body: PROVIDERS }, // mount GET
{ status: 500, body: { detail: 'disk full' } }, // save PUT fails
// nothing else queued: the /test POST must never fire
);
global.fetch = fetchMock;
render(<LLMProvidersPanel />);
fireEvent.click(await screen.findByTestId('llm-provider-test'));
// The save error is the single message
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/disk full/));
// with no contradictory green "Test ok" badge and no /test round-trip.
expect(screen.queryByText(/ok —/)).toBeNull();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/test'))).toBe(false);
});
it('a failed implicit save aborts Fetch models', async () => {
const fetchMock = mockFetchSequence(
{ body: PROVIDERS }, // mount GET
{ status: 500, body: { detail: 'disk full' } }, // save PUT fails
);
global.fetch = fetchMock;
render(<LLMProvidersPanel />);
fireEvent.click(await screen.findByTestId('llm-provider-models'));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/disk full/));
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/models'))).toBe(false);
});
it('initial-load failure offers a Retry that refetches (no remount needed)', async () => {
const fetchMock = mockFetchSequence(
{ status: 500, body: { detail: 'backend hiccup' } }, // mount GET fails
{ body: PROVIDERS }, // retry GET succeeds
);
global.fetch = fetchMock;
render(<LLMProvidersPanel />);
const retry = await screen.findByTestId('llm-provider-retry');
expect(screen.getByRole('alert')).toHaveTextContent(/backend hiccup/);
fireEvent.click(retry);
const select = await screen.findByTestId('llm-provider-select');
await waitFor(() => expect(select.value).toBe('groq'));
expect(screen.queryByTestId('llm-provider-retry')).toBeNull();
});
it('no notice when the saved provider IS the active one', async () => {
global.fetch = mockFetchSequence(
{ body: PROVIDERS }, // mount GET (active: groq)
@@ -136,6 +136,10 @@ export default function LLMSkillsPanel() {
value={skill.provider_override || ''}
onChange={(e) => update(skill.id, { provider_override: e.target.value })}
disabled={!skill.enabled || busy === skill.id}
aria-label={t('settings.llmskills_route_for', {
defaultValue: 'Provider for {{skill}}',
skill: t(skill.name_key),
})}
data-testid={`llm-skill-provider-${skill.id}`}
>
<option value="">{t('settings.llmskills_use_active')}</option>
@@ -121,6 +121,14 @@ describe('LLMSkillsPanel', () => {
expect(put.mock.calls[0][1]).toEqual({ provider_override: 'ollama' });
});
it('the per-skill routing Select carries an accessible name', async () => {
global.fetch = mockFetch(routes);
render(<LLMSkillsPanel />);
const select = await screen.findByTestId('llm-skill-provider-cinematic_translation');
// Announced as "Provider for <skill>" not an unlabeled combobox.
expect(select).toHaveAccessibleName('Provider for Cinematic & Autofit translation');
});
it('shows the needs-setup badge + LLM Providers link when no provider resolves', async () => {
const unready = {
skills: SKILLS.skills.map((s) => ({
+67 -3
View File
@@ -1,6 +1,9 @@
import React from 'react';
import { FileText, RefreshCw, Trash2, AlertCircle } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import { Copy, FileText, FolderOpen, RefreshCw, Trash2, AlertCircle } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { exportReveal } from '../../api/exports';
import { copyText } from '../../utils/copyText';
import { Segmented, Button, Badge } from '../../ui';
import { SettingsSection } from './primitives';
import ReportBugButton from '../ReportBugButton';
@@ -21,6 +24,37 @@ export default function LogsTab({
onClearLogs,
}) {
const { t } = useTranslation();
const scrollRef = useRef(null);
// Fresh log loads land scrolled to the newest entries the tail is the
// whole point of checking logs; without this the viewer opens at the oldest
// line of the tailed window on every refresh.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [logs]);
// The frontend "log" is an in-memory buffer there is no file to reveal.
const hasLogFile = logSource !== 'frontend' && !!logMeta.exists && !!logMeta.path;
const openLogFolder = async () => {
try {
await exportReveal({ path: logMeta.path });
} catch (e) {
toast.error(
e?.message || t('settings.open_folder_failed', { defaultValue: 'Could not open folder' }),
);
}
};
const copyLogs = async () => {
const ok = await copyText(logs.join(''));
if (ok) {
toast.success(t('logs.log_copied', { source: t(`common.${logSource}`) }));
} else {
toast.error(t('logs.copy_failed_short', { defaultValue: 'Could not copy the log' }));
}
};
return (
<SettingsSection
@@ -29,6 +63,16 @@ export default function LogsTab({
actions={
<>
<ReportBugButton />
<Button
variant="subtle"
size="sm"
onClick={copyLogs}
disabled={logs.length === 0}
leading={<Copy size={11} />}
data-testid="logs-copy"
>
{t('logs.copy_visible', { defaultValue: 'Copy visible log' })}
</Button>
<Button
variant="subtle"
size="sm"
@@ -48,17 +92,37 @@ export default function LogsTab({
items={LOG_SOURCE_DEFS.map((d) => ({ ...d, label: t(`common.${d.key}`) }))}
value={logSource}
onChange={setLogSource}
aria-label={t('logs.source', { defaultValue: 'Log source' })}
/>
<div className="settings-log-meta flex items-center gap-[var(--space-4)] my-[var(--space-4)] font-mono text-[var(--text-base)] text-[var(--chrome-fg-dim)]">
<span>{logMeta.path || '—'}</span>
{hasLogFile && (
<Button
variant="ghost"
size="sm"
onClick={openLogFolder}
leading={<FolderOpen size={11} />}
title={logMeta.path}
data-testid="logs-open-folder"
>
{t('settings.storage_open_folder', { defaultValue: 'Open folder' })}
</Button>
)}
{logSource === 'tauri' && !logMeta.exists && (
<Badge tone="warn">
<AlertCircle size={11} /> {t('logs.no_tauri_log')}
</Badge>
)}
</div>
<div className="bg-[var(--chrome-bg)] [border:1px_solid_var(--chrome-border)] rounded-[var(--chrome-radius-pill)] px-[12px] py-[10px] max-h-[280px] overflow-auto font-mono text-[0.72rem] text-[var(--chrome-fg-muted)] whitespace-pre-wrap break-words">
<div
ref={scrollRef}
tabIndex={0}
role="log"
aria-label={t('settings.logs')}
data-testid="logs-scroll"
className="bg-[var(--chrome-bg)] [border:1px_solid_var(--chrome-border)] rounded-[var(--chrome-radius-pill)] px-[12px] py-[10px] max-h-[280px] overflow-auto font-mono text-[0.72rem] text-[var(--chrome-fg-muted)] whitespace-pre-wrap break-words focus-visible:outline-none focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)]"
>
{logs.length === 0 ? (
<span className="settings-log__empty font-sans text-[var(--chrome-fg-dim)]">
{logSource === 'frontend'
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import LogsTab from './LogsTab';
const LINES = ['[10:00:00] boot\n', '[10:00:01] ready\n'];
function renderTab(overrides = {}) {
const props = {
logSource: 'backend',
setLogSource: vi.fn(),
logs: LINES,
logMeta: { path: '/home/u/.omnivoice/omnivoice.log', exists: true },
loadingLogs: false,
refreshLogs: vi.fn(),
onClearLogs: vi.fn(),
...overrides,
};
return { ...render(<LogsTab {...props} />), props };
}
describe('LogsTab', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('offers Open folder for on-disk logs and reveals via /export/reveal', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true }),
text: async () => '{"success":true}',
});
global.fetch = fetchMock;
renderTab();
fireEvent.click(screen.getByTestId('logs-open-folder'));
await waitFor(() => {
const call = fetchMock.mock.calls.find(([u]) => u.endsWith('/export/reveal'));
expect(call).toBeTruthy();
expect(JSON.parse(call[1].body)).toEqual({ path: '/home/u/.omnivoice/omnivoice.log' });
});
});
it('hides Open folder for the in-memory frontend buffer and missing files', () => {
renderTab({ logSource: 'frontend', logMeta: { path: 'in-memory (last 500)', exists: true } });
expect(screen.queryByTestId('logs-open-folder')).not.toBeInTheDocument();
renderTab({ logSource: 'tauri', logMeta: { path: '—', exists: false } });
expect(screen.queryByTestId('logs-open-folder')).not.toBeInTheDocument();
});
it('copies the visible tail to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
renderTab();
fireEvent.click(screen.getByTestId('logs-copy'));
await waitFor(() => expect(writeText).toHaveBeenCalledWith(LINES.join('')));
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
});
it('disables Copy when there is nothing to copy', () => {
renderTab({ logs: [] });
expect(screen.getByTestId('logs-copy')).toBeDisabled();
});
it('log viewport is keyboard-reachable and labelled', () => {
renderTab();
const box = screen.getByTestId('logs-scroll');
expect(box).toHaveAttribute('tabindex', '0');
expect(box).toHaveAttribute('role', 'log');
expect(box).toHaveAccessibleName('Logs');
});
it('scrolls to the newest entries when logs load', () => {
const { rerender, props } = renderTab({ logs: [] });
const box = screen.getByTestId('logs-scroll');
Object.defineProperty(box, 'scrollHeight', { value: 640, configurable: true });
rerender(<LogsTab {...props} logs={LINES} />);
expect(box.scrollTop).toBe(640);
});
});
@@ -9,19 +9,31 @@
* GET /api/mcp/bindings
* PUT /api/mcp/bindings {client_id, label?, profile_id?, default_engine?}
* DELETE /api/mcp/bindings/{client_id}
*
* The API's `default_engine` field is intentionally NOT editable here it is
* an MCP-side capability (agents can request an engine per docs/mcp.md); the
* panel only manages the voice routing a user actually reasons about.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Bot, Trash2 } from 'lucide-react';
import { apiJson, apiFetch } from '../../api/client';
import { listProfiles } from '../../api/profiles';
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
import { askConfirm } from './native';
import { SettingsSection, SettingRow, SettingsInput, InfoHint } from './primitives';
import { Button, Badge, Select } from '../../ui';
const MCP_DOCS_URL = 'https://github.com/debpalash/OmniVoice-Studio/blob/main/docs/mcp.md';
export default function MCPBindingsPanel() {
const { t } = useTranslation();
const [bindings, setBindings] = useState([]);
const [profiles, setProfiles] = useState([]);
const [clientId, setClientId] = useState('');
const [label, setLabel] = useState('');
const [profileId, setProfileId] = useState('');
const [adding, setAdding] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
@@ -31,47 +43,92 @@ export default function MCPBindingsPanel() {
setBindings(b);
setProfiles(p);
} catch (e) {
setError(e?.message || 'Failed to load MCP bindings');
setError(
e?.message ||
t('settings.mcp_load_failed', { defaultValue: 'Failed to load MCP bindings' }),
);
}
}, []);
}, [t]);
useEffect(() => {
refresh();
}, [refresh]);
const profileName = (id) => profiles.find((p) => p.id === id)?.name || id || '—';
const profileName = (id) =>
profiles.find((p) => p.id === id)?.name ||
id ||
t('settings.mcp_default_voice', { defaultValue: 'Default voice' });
const onAdd = async () => {
if (!clientId.trim()) return;
if (!clientId.trim() || adding) return;
setAdding(true);
setError(null);
try {
await apiFetch('/api/mcp/bindings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId.trim(), profile_id: profileId || null }),
body: JSON.stringify({
client_id: clientId.trim(),
label: label.trim() || null,
profile_id: profileId || null,
}),
});
setClientId('');
setLabel('');
setProfileId('');
refresh();
await refresh();
} catch (e) {
setError(e?.message || 'Failed to save binding');
setError(
e?.message || t('settings.mcp_save_failed', { defaultValue: 'Failed to save binding' }),
);
} finally {
setAdding(false);
}
};
const onDelete = async (cid) => {
if (deletingId) return;
const confirmed = await askConfirm(
t('settings.mcp_delete_confirm', {
defaultValue: 'Remove the voice binding for “{{clientId}}”?',
clientId: cid,
}),
t('settings.mcp_delete_confirm_title', { defaultValue: 'Remove binding' }),
);
if (!confirmed) return;
setDeletingId(cid);
setError(null);
let failure = null;
try {
await apiFetch(`/api/mcp/bindings/${encodeURIComponent(cid)}`, { method: 'DELETE' });
refresh();
} catch (e) {
setError(e?.message || 'Failed to delete binding');
failure =
e?.message || t('settings.mcp_delete_failed', { defaultValue: 'Failed to delete binding' });
}
// Re-sync even on failure: a 404 means the row was already gone the list
// must not keep showing it. refresh() clears error state, so re-apply the
// delete failure afterwards.
await refresh();
if (failure) setError(failure);
setDeletingId(null);
};
return (
<SettingsSection
icon={Bot}
title="MCP voice bindings"
description="Bind an agent's client id to a voice profile."
title={t('settings.mcp_title', { defaultValue: 'MCP voice bindings' })}
description={t('settings.mcp_desc', {
defaultValue:
'Give each MCP agent its own voice — bind the client id an agent sends to a voice profile.',
})}
actions={
<InfoHint learnMoreHref={MCP_DOCS_URL}>
{t('settings.mcp_hint', {
defaultValue:
'Agents reach OmniVoice at /mcp and identify themselves with a client id (e.g. claude-code). Bind that id to a voice so the agent always speaks in that profile.',
})}
</InfoHint>
}
>
{error && (
<div className="perfpanel__error" role="alert">
@@ -79,16 +136,22 @@ export default function MCPBindingsPanel() {
</div>
)}
{bindings.length === 0 && !error && (
<p
className="m-0 py-[var(--space-3)] text-[length:var(--text-xs)] text-[color:var(--chrome-fg-dim)] leading-[1.5]"
data-testid="mcp-empty"
>
{t('settings.mcp_empty', {
defaultValue: "No bindings yet — add an agent's client id below.",
})}
</p>
)}
{bindings.map((b) => (
<SettingRow
key={b.client_id}
title={b.label || b.client_id}
hint={
<>
Agents reach OmniVoice at <code>/mcp</code>. Bind an agent's client id to a voice so
it speaks in that profile. See <code>docs/mcp.md</code>.
</>
}
subtitle={b.label ? b.client_id : undefined}
control={
<>
<Badge tone="neutral">{profileName(b.profile_id)}</Badge>
@@ -96,7 +159,11 @@ export default function MCPBindingsPanel() {
variant="danger"
size="sm"
onClick={() => onDelete(b.client_id)}
aria-label={`Remove ${b.client_id}`}
disabled={deletingId === b.client_id}
aria-label={t('settings.mcp_remove', {
defaultValue: 'Remove {{clientId}}',
clientId: b.client_id,
})}
data-testid={`mcp-del-${b.client_id}`}
>
<Trash2 size={12} />
@@ -107,31 +174,54 @@ export default function MCPBindingsPanel() {
))}
<SettingRow
title="Add binding"
title={t('settings.mcp_add_title', { defaultValue: 'Add binding' })}
stack
control={
<>
<SettingsInput
type="text"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="client id (e.g. claude-code)"
placeholder={t('settings.mcp_client_id_placeholder', {
defaultValue: 'Client ID (e.g. claude-code)',
})}
aria-label={t('settings.mcp_client_id', { defaultValue: 'Client ID' })}
data-testid="mcp-client-id"
/>
<SettingsInput
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('settings.mcp_label_placeholder', {
defaultValue: 'Label (optional)',
})}
aria-label={t('settings.mcp_label', { defaultValue: 'Label' })}
data-testid="mcp-label"
/>
<Select
size="sm"
value={profileId}
onChange={(e) => setProfileId(e.target.value)}
aria-label={t('settings.mcp_voice_profile', { defaultValue: 'Voice profile' })}
data-testid="mcp-profile"
>
<option value="">default voice</option>
<option value="">
{t('settings.mcp_default_voice', { defaultValue: 'Default voice' })}
</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Select>
<Button variant="subtle" size="sm" onClick={onAdd} data-testid="mcp-add">
Bind
<Button
variant="subtle"
size="sm"
onClick={onAdd}
disabled={!clientId.trim() || adding}
data-testid="mcp-add"
>
{t('settings.mcp_add', { defaultValue: 'Add binding' })}
</Button>
</>
}
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
// Deterministic confirm: tests flip `confirmAnswer` per case (the real
// askConfirm routes through the Tauri dialog plugin / window.confirm).
let confirmAnswer = true;
const askConfirmMock = vi.fn(async () => confirmAnswer);
vi.mock('./native', () => ({
isTauri: () => false,
askConfirm: (...args) => askConfirmMock(...args),
}));
const PROFILES = [
{ id: 'morgan', name: 'Morgan' },
{ id: 'scarlett', name: 'Scarlett' },
];
vi.mock('../../api/profiles', () => ({
listProfiles: vi.fn(async () => PROFILES),
}));
import MCPBindingsPanel from './MCPBindingsPanel';
const BINDINGS = [
{ client_id: 'claude-code', label: 'Claude Code', profile_id: 'morgan' },
{ client_id: 'cursor', label: null, profile_id: null },
];
function mockFetchSequence(...responses) {
const fn = vi.fn();
for (const r of responses) {
fn.mockResolvedValueOnce({
ok: (r.status ?? 200) >= 200 && (r.status ?? 200) < 300,
status: r.status ?? 200,
json: async () => r.body,
text: async () => JSON.stringify(r.body),
});
}
return fn;
}
describe('MCPBindingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
confirmAnswer = true;
});
it('renders bindings with label (falling back to client id) and profile badge', async () => {
global.fetch = mockFetchSequence({ body: BINDINGS });
render(<MCPBindingsPanel />);
expect(await screen.findByText('Claude Code')).toBeInTheDocument();
// Unlabelled binding falls back to its client id as the row title.
expect(screen.getByText('cursor')).toBeInTheDocument();
// Profile badge on the bound row ("Morgan" also exists as a select option).
expect(screen.getAllByText('Morgan').some((el) => el.tagName !== 'OPTION')).toBe(true);
});
it('empty list shows the first-run guidance instead of a bare add row', async () => {
global.fetch = mockFetchSequence({ body: [] });
render(<MCPBindingsPanel />);
expect(await screen.findByTestId('mcp-empty')).toHaveTextContent(/No bindings yet/);
});
it('load failure surfaces the error (and no stale empty-state hint)', async () => {
// HTTP 500 (not a transport error) apiFetch never retries HTTP errors,
// so the test stays fast and deterministic.
global.fetch = mockFetchSequence({ status: 500, body: { detail: 'boom' } });
render(<MCPBindingsPanel />);
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
expect(screen.queryByTestId('mcp-empty')).toBeNull();
});
it('Add binding PUTs client id + optional label + profile, then refreshes', async () => {
const fetchMock = mockFetchSequence(
{ body: [] }, // mount GET
{ body: { client_id: 'cline', label: 'Cline', profile_id: 'scarlett' } }, // PUT
{ body: [{ client_id: 'cline', label: 'Cline', profile_id: 'scarlett' }] }, // refresh GET
);
global.fetch = fetchMock;
render(<MCPBindingsPanel />);
await screen.findByTestId('mcp-empty');
fireEvent.change(screen.getByTestId('mcp-client-id'), { target: { value: ' cline ' } });
fireEvent.change(screen.getByTestId('mcp-label'), { target: { value: 'Cline' } });
fireEvent.change(screen.getByTestId('mcp-profile'), { target: { value: 'scarlett' } });
fireEvent.click(screen.getByTestId('mcp-add'));
await waitFor(() => {
const put = fetchMock.mock.calls.find(([, opts]) => opts?.method === 'PUT');
expect(put).toBeTruthy();
expect(put[0]).toMatch(/\/api\/mcp\/bindings$/);
expect(JSON.parse(put[1].body)).toEqual({
client_id: 'cline',
label: 'Cline',
profile_id: 'scarlett',
});
});
// Inputs reset after a successful add; the new row renders.
await screen.findByText('Cline');
expect(screen.getByTestId('mcp-client-id').value).toBe('');
});
it('Add button is disabled with an empty client id', async () => {
global.fetch = mockFetchSequence({ body: [] });
render(<MCPBindingsPanel />);
await screen.findByTestId('mcp-empty');
expect(screen.getByTestId('mcp-add')).toBeDisabled();
});
it('delete asks for confirmation and DELETEs on confirm', async () => {
const fetchMock = mockFetchSequence(
{ body: BINDINGS }, // mount GET
{ body: { deleted: 'cursor' } }, // DELETE
{ body: [BINDINGS[0]] }, // refresh GET
);
global.fetch = fetchMock;
render(<MCPBindingsPanel />);
fireEvent.click(await screen.findByTestId('mcp-del-cursor'));
await waitFor(() => {
expect(askConfirmMock).toHaveBeenCalledWith(
expect.stringContaining('cursor'),
expect.any(String),
);
const del = fetchMock.mock.calls.find(([, opts]) => opts?.method === 'DELETE');
expect(del).toBeTruthy();
expect(del[0]).toMatch(/\/api\/mcp\/bindings\/cursor$/);
});
await waitFor(() => expect(screen.queryByTestId('mcp-del-cursor')).toBeNull());
});
it('declining the confirmation sends no DELETE', async () => {
confirmAnswer = false;
const fetchMock = mockFetchSequence({ body: BINDINGS });
global.fetch = fetchMock;
render(<MCPBindingsPanel />);
fireEvent.click(await screen.findByTestId('mcp-del-cursor'));
await waitFor(() => expect(askConfirmMock).toHaveBeenCalled());
expect(fetchMock.mock.calls.find(([, opts]) => opts?.method === 'DELETE')).toBeUndefined();
});
it('a failed delete (already gone: 404) still re-syncs the list', async () => {
const fetchMock = mockFetchSequence(
{ body: BINDINGS }, // mount GET
{ status: 404, body: { detail: 'No binding for that client id' } }, // DELETE fails
{ body: [BINDINGS[0]] }, // refresh GET row is gone server-side
);
global.fetch = fetchMock;
render(<MCPBindingsPanel />);
fireEvent.click(await screen.findByTestId('mcp-del-cursor'));
// The stale row disappears even though the DELETE errored.
await waitFor(() => expect(screen.queryByTestId('mcp-del-cursor')).toBeNull());
});
it('controls carry accessible names', async () => {
global.fetch = mockFetchSequence({ body: BINDINGS });
render(<MCPBindingsPanel />);
await screen.findByText('Claude Code');
expect(screen.getByLabelText('Client ID')).toBeInTheDocument();
expect(screen.getByLabelText('Label')).toBeInTheDocument();
expect(screen.getByLabelText('Voice profile')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove cursor' })).toBeInTheDocument();
});
});
@@ -11,6 +11,7 @@ import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { openExternal } from '../../api/external';
import { setupDownloadStreamUrl } from '../../api/setup';
import { listLoadedModels, unloadLoadedModel } from '../../api/system';
import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../../api/hooks';
import { Button, Segmented } from '../../ui';
import { SettingsSection, SettingsInput, SETTINGS_SECTION_SURFACE } from './primitives';
@@ -158,10 +159,51 @@ export default function ModelStoreTab({ info, modelBadge }) {
return () => clearTimeout(t);
}, [rowState, modelsQuery, recoQuery]);
// Memory residency: repo_id (checkpoint) its /model/loaded entry. Marks
// rows whose weights are resident in RAM/VRAM right now and enables the
// Unload affordance where the backend says the entry is unloadable.
// Advisory a fetch failure just means no chips, never a broken tab.
const [loadedModels, setLoadedModels] = useState([]);
const refreshLoaded = useCallback(async () => {
try {
const res = await listLoadedModels();
setLoadedModels(res?.models || []);
} catch {
setLoadedModels([]);
}
}, []);
useEffect(() => {
refreshLoaded();
}, [refreshLoaded]);
const residencyByRepo = useMemo(() => {
const map = {};
for (const lm of loadedModels) {
if (lm?.checkpoint) map[lm.checkpoint] = lm;
}
return map;
}, [loadedModels]);
const getResidency = useCallback((m) => residencyByRepo[m.repo_id] || null, [residencyByRepo]);
const onUnload = useCallback(
async (repoId) => {
const entry = residencyByRepo[repoId];
if (!entry) return;
try {
await unloadLoadedModel(entry.id);
toast.success(t('models.unloaded_toast'));
} catch (e) {
toast.error(t('models.unload_failed', { message: e.message || String(e) }));
} finally {
refreshLoaded();
}
},
[residencyByRepo, refreshLoaded, t],
);
const reload = useCallback(() => {
modelsQuery.refetch();
recoQuery.refetch();
}, [modelsQuery, recoQuery]);
refreshLoaded();
}, [modelsQuery, recoQuery, refreshLoaded]);
const withBusy = useCallback(async (repoId, fn, successMsg) => {
setBusy((prev) => new Set(prev).add(repoId));
@@ -311,6 +353,8 @@ export default function ModelStoreTab({ info, modelBadge }) {
onReinstall,
onCancel,
onDismissError,
getResidency,
onUnload,
}),
[
getRowRuntime,
@@ -319,6 +363,8 @@ export default function ModelStoreTab({ info, modelBadge }) {
onReinstall,
onCancel,
onDismissError,
getResidency,
onUnload,
MODEL_ROLE_LABEL,
t,
],
@@ -355,7 +401,9 @@ export default function ModelStoreTab({ info, modelBadge }) {
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => tableBodyRef.current,
estimateSize: () => 68,
// Matches the compact two-line .models-row min-height (52px) rows with
// a live progress/error block re-measure and grow past this.
estimateSize: () => 54,
overscan: 8,
});
@@ -483,6 +531,7 @@ export default function ModelStoreTab({ info, modelBadge }) {
installingReco={installingReco}
setInstallingReco={setInstallingReco}
onInstallRecommended={onInstallRecommended}
diskFreeGb={data.disk_free_gb}
/>
<div className="my-[var(--space-2)] flex items-center gap-[var(--space-2)] max-[580px]:flex-col max-[580px]:items-stretch">
@@ -522,6 +571,10 @@ export default function ModelStoreTab({ info, modelBadge }) {
tableBodyRef={tableBodyRef}
getRowRuntime={getRowRuntime}
t={t}
onClearFilters={() => {
setQuery('');
setActiveRole('all');
}}
/>
</section>
);
+41 -64
View File
@@ -1,19 +1,17 @@
/**
* Settings Network.
*
* The proxy + FFmpeg-path controls that used to live in GeneralTab's "Advanced"
* collapsible, promoted to their own top-level category. Logic is unchanged
* both persist via the backend `/system/set-env` durable env writer and
* invalidate the systemInfo query so badges refresh.
*
* FFmpeg takes effect on the next backend start (durable env), so it carries a
* RestartBadge; the proxy applies to subsequent downloads immediately.
* Proxy only. The FFmpeg-path override that used to share this panel moved to
* Settings Audio tools (same backend store prefs `env.FFMPEG_PATH` via
* `/media-tools` richer controls: version, origin, restore bundled); a
* pointer row below deep-links there so muscle memory still lands.
*/
import React, { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { Wifi, Globe, Film } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '../../store';
import { useSystemInfo, queryKeys } from '../../api/hooks';
import { Button, Badge } from '../../ui';
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
@@ -24,41 +22,21 @@ export default function NetworkTab() {
const { data: sysInfo } = useSystemInfo();
const [proxyUrl, setProxyUrl] = useState('');
const [proxySaved, setProxySaved] = useState(false);
const [proxyCleared, setProxyCleared] = useState(false);
const [proxySaving, setProxySaving] = useState(false);
const [ffmpegPath, setFfmpegPath] = useState('');
const [ffmpegSaving, setFfmpegSaving] = useState(false);
const queryClient = useQueryClient();
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
useEffect(() => {
if (!proxyUrl && !proxySaved) setProxyUrl(sysInfo?.proxy_url || '');
if (!proxyUrl && !proxySaved && !proxyCleared) setProxyUrl(sysInfo?.proxy_url || '');
}, [sysInfo?.proxy_url]);
useEffect(() => {
if (!ffmpegPath) setFfmpegPath(sysInfo?.ffmpeg_path || '');
}, [sysInfo?.ffmpeg_path]);
const ffmpegOk = sysInfo?.ffmpeg_ok;
const ffmpegCurrent = sysInfo?.ffmpeg_path;
const saveFfmpeg = async () => {
const value = ffmpegPath.trim();
setFfmpegSaving(true);
try {
const { apiFetch } = await import('../../api/client');
await apiFetch('/system/set-env', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'FFMPEG_PATH', value }),
});
toast.success(t('settings.ffmpeg_saved'));
setFfmpegPath('');
queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
} catch (e) {
toast.error(t('settings.save_failed', { message: e.message }));
} finally {
setFfmpegSaving(false);
}
};
// "A proxy is configured" must survive an app reload: derive it from the
// backend-persisted value, not only from a save in this session otherwise
// the Clear button (and the "Set" badge) vanish on reload with the proxy
// still active and no way to remove it.
const proxyConfigured = !proxyCleared && (proxySaved || Boolean(sysInfo?.proxy_url));
const saveProxy = async () => {
const value = proxyUrl.trim();
@@ -81,6 +59,7 @@ export default function NetworkTab() {
]);
toast.success(t('settings.proxy_saved'));
setProxySaved(true);
setProxyCleared(false);
queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
} catch (e) {
toast.error(t('settings.save_failed', { message: e.message }));
@@ -109,6 +88,7 @@ export default function NetworkTab() {
]);
setProxyUrl('');
setProxySaved(false);
setProxyCleared(true);
toast.success(t('settings.proxy_cleared'));
queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
} catch (e) {
@@ -123,7 +103,7 @@ export default function NetworkTab() {
icon={Wifi}
title={t('settings.network', { defaultValue: 'Network' })}
description={t('settings.network_desc', {
defaultValue: 'Proxy and FFmpeg paths for downloads and media processing.',
defaultValue: 'Proxy for downloads and model fetches.',
})}
>
<SettingRow
@@ -133,7 +113,8 @@ export default function NetworkTab() {
title={
<>
{t('settings.proxy')}
{proxySaved && (
<RestartBadge applies />
{proxyConfigured && (
<Badge tone="success" size="xs">
{t('credentials.saved')}
</Badge>
@@ -148,6 +129,7 @@ export default function NetworkTab() {
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && saveProxy()}
aria-label={t('settings.proxy_input_aria', { defaultValue: 'Proxy URL' })}
/>
<Button
size="sm"
@@ -158,8 +140,14 @@ export default function NetworkTab() {
>
{t('credentials.save')}
</Button>
{proxySaved && (
<Button size="sm" variant="ghost" onClick={clearProxy} loading={proxySaving}>
{proxyConfigured && (
<Button
size="sm"
variant="ghost"
onClick={clearProxy}
loading={proxySaving}
data-testid="proxy-clear"
>
{t('settings.proxy_clear')}
</Button>
)}
@@ -167,9 +155,9 @@ export default function NetworkTab() {
}
/>
{/* Pointer, not a control the FFmpeg override lives in Audio tools now.
Two competing writers of env.FFMPEG_PATH would fight each other. */}
<SettingRow
align="start"
stack
icon={Film}
title={
<>
@@ -177,32 +165,21 @@ export default function NetworkTab() {
<Badge tone={ffmpegOk ? 'success' : 'warn'} size="xs">
{ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')}
</Badge>
<RestartBadge />
</>
}
note={
ffmpegCurrent
? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}`
: t('settings.ffmpeg_desc')
}
note={t('settings.audio_tools_moved_note', {
defaultValue:
'The FFmpeg override moved to its own panel with more control (version, origin, restore).',
})}
control={
<>
<SettingsInput
placeholder="D:\ffmpeg\bin\ffmpeg.exe"
value={ffmpegPath}
onChange={(e) => setFfmpegPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && saveFfmpeg()}
/>
<Button
size="sm"
variant="subtle"
onClick={saveFfmpeg}
loading={ffmpegSaving}
disabled={!ffmpegPath.trim()}
>
{t('credentials.save')}
</Button>
</>
<Button
size="sm"
variant="ghost"
onClick={() => openSettingsTab('audio-tools')}
data-testid="open-audio-tools"
>
{t('settings.audio_tools_open', { defaultValue: 'Open Audio tools' })}
</Button>
}
/>
</SettingsSection>
@@ -0,0 +1,118 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
// Keep toast side-channels out of the test (timers, portals).
vi.mock('react-hot-toast', () => ({
default: { error: vi.fn(), success: vi.fn() },
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('../../api/hooks', () => ({
useSystemInfo: vi.fn(),
queryKeys: { systemInfo: ['system-info'] },
}));
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock('../../api/client', () => ({
apiFetch: vi.fn().mockResolvedValue({}),
}));
const { openSettingsTab } = vi.hoisted(() => ({ openSettingsTab: vi.fn() }));
vi.mock('../../store', () => ({
useAppStore: (selector) => selector({ openSettingsTab }),
}));
import { toast } from 'react-hot-toast';
import { useSystemInfo } from '../../api/hooks';
import { apiFetch } from '../../api/client';
import NetworkTab from './NetworkTab';
describe('NetworkTab', () => {
beforeEach(() => {
vi.clearAllMocks();
apiFetch.mockResolvedValue({});
});
it('offers Clear for a proxy persisted in a previous session (after reload)', async () => {
// Fresh mount, nothing saved this session the persisted proxy comes
// from the backend. The Clear affordance must NOT depend on having just
// clicked Save in the current session.
useSystemInfo.mockReturnValue({ data: { proxy_url: 'http://127.0.0.1:7890' } });
render(<NetworkTab />);
// Input is prefilled from the persisted value, the "Set" badge shows,
// and Clear is available immediately.
expect(screen.getByLabelText('Proxy URL')).toHaveValue('http://127.0.0.1:7890');
expect(screen.getByText('✓ Set')).toBeInTheDocument();
const clear = screen.getByTestId('proxy-clear');
fireEvent.click(clear);
await waitFor(() => {
// All six proxy env vars are cleared on the backend.
const clearedKeys = apiFetch.mock.calls
.filter(([path]) => path === '/system/set-env')
.map(([, opts]) => JSON.parse(opts.body))
.filter((b) => b.value === '')
.map((b) => b.key)
.sort();
expect(clearedKeys).toEqual([
'ALL_PROXY',
'HTTPS_PROXY',
'HTTP_PROXY',
'all_proxy',
'http_proxy',
'https_proxy',
]);
});
// The UI reflects the cleared state without waiting for a refetch.
await waitFor(() => {
expect(screen.queryByTestId('proxy-clear')).not.toBeInTheDocument();
});
expect(screen.getByLabelText('Proxy URL')).toHaveValue('');
expect(screen.queryByText('✓ Set')).not.toBeInTheDocument();
});
it('hides Clear when no proxy is configured', () => {
useSystemInfo.mockReturnValue({ data: { proxy_url: '' } });
render(<NetworkTab />);
expect(screen.queryByTestId('proxy-clear')).not.toBeInTheDocument();
expect(screen.queryByText('✓ Set')).not.toBeInTheDocument();
});
it('shows Clear (and the badge) right after saving in this session', async () => {
useSystemInfo.mockReturnValue({ data: { proxy_url: '' } });
render(<NetworkTab />);
fireEvent.change(screen.getByLabelText('Proxy URL'), {
target: { value: 'socks5://127.0.0.1:7890' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.getByTestId('proxy-clear')).toBeInTheDocument();
expect(screen.getByText('✓ Set')).toBeInTheDocument();
});
it('labels the proxy input for assistive tech', () => {
useSystemInfo.mockReturnValue({ data: {} });
render(<NetworkTab />);
expect(screen.getByLabelText('Proxy URL')).toBeInTheDocument();
});
it('has NO FFmpeg path control anymore — only the pointer to Audio tools', () => {
// The override moved to Settings Audio tools; a second writer of
// env.FFMPEG_PATH here would fight the new panel.
useSystemInfo.mockReturnValue({ data: { ffmpeg_ok: true } });
render(<NetworkTab />);
expect(screen.queryByLabelText('FFmpeg path')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('open-audio-tools'));
expect(openSettingsTab).toHaveBeenCalledWith('audio-tools');
});
});
@@ -68,7 +68,17 @@ export default function OpenApiPanel() {
const copyUrl = useCallback(async () => {
const ok = await copyText(specUrl);
if (ok) toast.success(t('openapi.copied', { defaultValue: 'Spec URL copied' }));
if (ok) {
toast.success(t('openapi.copied', { defaultValue: 'Spec URL copied' }));
} else {
// copyText returns false when both clipboard paths fail (e.g. a
// non-secure LAN-share context) never leave the click unanswered.
toast.error(
t('openapi.copy_failed', {
defaultValue: 'Copy failed — select and copy the URL above manually.',
}),
);
}
}, [specUrl, t]);
const openRaw = useCallback(() => {
@@ -15,8 +15,17 @@ vi.mock('../../api/client', () => ({
apiFetch: vi.fn(),
}));
// Clipboard helper + toast controlled so the copy affordance's success AND
// failure feedback can both be asserted.
vi.mock('../../utils/copyText', () => ({ copyText: vi.fn() }));
vi.mock('react-hot-toast', () => ({
default: { error: vi.fn(), success: vi.fn() },
}));
import OpenApiPanel from './OpenApiPanel';
import { apiFetch } from '../../api/client';
import { copyText } from '../../utils/copyText';
import toast from 'react-hot-toast';
const MINIMAL_SPEC = {
openapi: '3.1.0',
@@ -73,4 +82,28 @@ describe('OpenApiPanel', () => {
expect(await screen.findByTestId('scalar-mock')).toBeInTheDocument();
expect(screen.queryByTestId('openapi-unreachable')).not.toBeInTheDocument();
});
it('toasts success when the spec URL copies', async () => {
apiFetch.mockResolvedValue({ json: async () => MINIMAL_SPEC });
copyText.mockResolvedValue(true);
render(<OpenApiPanel />);
fireEvent.click(screen.getByTestId('openapi-copy-url'));
await vi.waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(copyText).toHaveBeenCalledWith('http://127.0.0.1:3900/openapi.json');
expect(toast.error).not.toHaveBeenCalled();
});
it('toasts an error when the clipboard copy fails (non-secure context)', async () => {
apiFetch.mockResolvedValue({ json: async () => MINIMAL_SPEC });
copyText.mockResolvedValue(false);
render(<OpenApiPanel />);
fireEvent.click(screen.getByTestId('openapi-copy-url'));
// A failed copy must never be silent.
await vi.waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(toast.success).not.toHaveBeenCalled();
});
});
@@ -70,7 +70,13 @@ export default function PerformanceDeviceTab() {
: 'neutral'
}
>
{status?.status || 'unknown'}
{status?.status === 'ready'
? t('models.ready_badge')
: status?.status === 'loading'
? t('models.loading_badge')
: status?.status === 'idle'
? t('models.idle_badge')
: status?.status || t('common.unknown', { defaultValue: 'unknown' })}
</Badge>
}
/>
@@ -17,11 +17,13 @@
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Cpu } from 'lucide-react';
import { Trans, useTranslation } from 'react-i18next';
import { apiJson, apiFetch } from '../../api/client';
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
import RestartBadge from './RestartBadge';
export default function PerformancePanel() {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
const [platform, setPlatform] = useState(null);
const [loading, setLoading] = useState(false);
@@ -36,11 +38,14 @@ export default function PerformancePanel() {
setEnabled(Boolean(data?.enabled));
setPlatform(data?.platform ?? null);
} catch (e) {
setError(e?.message || 'Failed to load performance settings');
setError(
e?.message ||
t('settings.perf_load_failed', { defaultValue: 'Failed to load performance settings' }),
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
refresh();
@@ -60,7 +65,9 @@ export default function PerformancePanel() {
const body = await res.json().catch(() => ({}));
setEnabled(Boolean(body?.enabled ?? next));
} catch (err) {
setError(err?.message || 'Failed to save setting');
setError(
err?.message || t('settings.perf_save_failed', { defaultValue: 'Failed to save setting' }),
);
// Re-sync on failure so the UI doesn't show a stale state
refresh();
} finally {
@@ -68,8 +75,12 @@ export default function PerformancePanel() {
}
};
const toggleLabel = t('settings.perf_torch_compile', {
defaultValue: 'Disable torch.compile (Windows)',
});
return (
<SettingsSection icon={Cpu} title="Performance">
<SettingsSection icon={Cpu} title={t('settings.perf_title', { defaultValue: 'Performance' })}>
{error && (
<div className="perfpanel__error" role="alert">
{error}
@@ -79,33 +90,49 @@ export default function PerformancePanel() {
<SettingRow
title={
<>
Disable torch.compile (Windows)
{toggleLabel}
<RestartBadge />
</>
}
subtitle={!isWindows ? (platform === null ? '…' : 'not applicable') : undefined}
note={isWindows ? 'Falls back to eager mode — fixes Triton OOM on <16 GB GPUs.' : undefined}
subtitle={
!isWindows
? platform === null
? '…'
: t('settings.perf_torch_compile_na', {
defaultValue: 'Windows only — not needed on this platform',
})
: undefined
}
note={
isWindows
? t('settings.perf_torch_compile_note', {
defaultValue: 'Falls back to eager mode — fixes Triton OOM on <16 GB GPUs.',
})
: undefined
}
hint={
<>
Workaround for{' '}
<a
href="https://github.com/debpalash/OmniVoice-Studio/issues/65"
target="_blank"
rel="noopener noreferrer"
>
#65
</a>{' '}
Windows users may hit Triton / <code>torch.compile</code> OOM during model load on
GPUs with &lt;16 GB VRAM. Enabling this sets <code>TORCH_COMPILE_DISABLE=1</code> on
engine subprocesses, which falls back to eager mode. macOS and Linux are unaffected.
</>
<Trans
i18nKey="settings.perf_torch_compile_hint"
defaults="Workaround for <issueLink>#65</issueLink> — Windows users may hit Triton / <code>torch.compile</code> OOM during model load on GPUs with less than 16 GB VRAM. Enabling this sets <code>TORCH_COMPILE_DISABLE=1</code> on engine subprocesses, which falls back to eager mode. macOS and Linux are unaffected."
components={{
// Trans injects the link text ("#65") from the translation string.
issueLink: (
<a
href="https://github.com/debpalash/OmniVoice-Studio/issues/65"
target="_blank"
rel="noopener noreferrer"
/>
),
code: <code />,
}}
/>
}
control={
<SettingsToggle
checked={enabled}
onChange={onToggle}
disabled={!isWindows || saving || loading}
aria-label="Disable torch.compile (Windows)"
aria-label={toggleLabel}
data-testid="torch-compile-toggle"
/>
}
@@ -65,7 +65,30 @@ describe('PerformancePanel', () => {
const toggle = screen.getByTestId('torch-compile-toggle');
expect(toggle).toBeDisabled();
});
expect(screen.getByText(/not applicable/i)).toBeInTheDocument();
expect(screen.getByText(/not needed on this platform/i)).toBeInTheDocument();
});
it('renders every user-facing string through i18n (en fallback)', async () => {
global.fetch = mockFetchSequence({
status: 200,
body: { enabled: false, platform: 'win32' },
});
render(<PerformancePanel />);
await waitFor(() => screen.getByTestId('torch-compile-toggle'));
// Section title + row label resolve from settings.perf_* keys.
expect(screen.getByText('Performance')).toBeInTheDocument();
expect(screen.getByText(/Disable torch\.compile \(Windows\)/)).toBeInTheDocument();
expect(screen.getByText(/Falls back to eager mode/)).toBeInTheDocument();
expect(screen.getByTestId('torch-compile-toggle')).toHaveAccessibleName(
/Disable torch\.compile \(Windows\)/,
);
});
it('surfaces a translated load error when the GET fails', async () => {
global.fetch = mockFetchSequence({ status: 500, body: { detail: 'boom' } });
render(<PerformancePanel />);
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
expect(screen.getByRole('alert')).toHaveTextContent(/boom|Failed to load/i);
});
it('renders disabled on linux platform', async () => {
+45 -19
View File
@@ -1,12 +1,55 @@
import React from 'react';
import { ShieldCheck, CheckCircle, AlertCircle } from 'lucide-react';
import { Trans, useTranslation } from 'react-i18next';
import { Badge } from '../../ui';
import { Badge, Button } from '../../ui';
import { useAppStore } from '../../store';
import { SettingsSection } from './primitives';
import Row from './Row';
// Providers that send dialogue text to a third-party service vs. the ones that
// run fully on-device (backend/api/routers/dub_translate.py). Anything else
// including the backend's safe-defaults value 'unknown' or a missing
// system-info payload must NOT get the confident green "offline" claim.
const ONLINE_PROVIDERS = ['google', 'deepl', 'mymemory', 'microsoft', 'openai'];
const OFFLINE_PROVIDERS = ['nllb', 'argos', 'libretranslate'];
export default function PrivacyTab({ info }) {
const { t } = useTranslation();
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
const provider = info?.translate_provider;
let translatorBadge;
if (provider && ONLINE_PROVIDERS.includes(provider)) {
translatorBadge = (
<span className="inline-flex items-center gap-[var(--space-2)]">
<Badge tone="warn">
<AlertCircle size={11} /> {t('privacy.translator_online', { provider })}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => openSettingsTab('translation')}
data-testid="privacy-change-translator"
>
{t('privacy.change_translator', { defaultValue: 'Change translator' })}
</Button>
</span>
);
} else if (provider && OFFLINE_PROVIDERS.includes(provider)) {
translatorBadge = (
<Badge tone="success">
<CheckCircle size={11} /> {t('privacy.translator_offline')}
</Badge>
);
} else {
// Backend down, errored (translate_provider: 'unknown'), or an
// unrecognized provider don't render a privacy assurance without data.
translatorBadge = (
<Badge tone="neutral" data-testid="privacy-translator-unknown">
{t('privacy.translator_unknown', { defaultValue: 'Unknown' })}
</Badge>
);
}
return (
<SettingsSection icon={ShieldCheck} title={t('settings.privacy')}>
@@ -23,24 +66,7 @@ export default function PrivacyTab({ info }) {
label={t('privacy.gen_history')}
value={<Badge tone="neutral">{t('privacy.local_sqlite')}</Badge>}
/>
<Row
label={t('privacy.network_calls')}
value={
info?.translate_provider &&
['google', 'deepl', 'mymemory', 'microsoft', 'openai'].includes(
info.translate_provider,
) ? (
<Badge tone="warn">
<AlertCircle size={11} />{' '}
{t('privacy.translator_online', { provider: info.translate_provider })}
</Badge>
) : (
<Badge tone="success">
<CheckCircle size={11} /> {t('privacy.translator_offline')}
</Badge>
)
}
/>
<Row label={t('privacy.network_calls')} value={translatorBadge} />
<Row
label={t('privacy.model_telemetry')}
value={
@@ -0,0 +1,43 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
// Mock the zustand store for the openSettingsTab deep-link action.
const { openSettingsTab } = vi.hoisted(() => ({ openSettingsTab: vi.fn() }));
vi.mock('../../store', () => ({
useAppStore: (selector) => selector({ openSettingsTab }),
}));
import PrivacyTab from './PrivacyTab';
describe('PrivacyTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('does not claim "Offline translator" when system info is missing (backend down)', () => {
render(<PrivacyTab info={undefined} />);
expect(screen.getByTestId('privacy-translator-unknown')).toBeInTheDocument();
expect(screen.queryByText('Offline translator')).not.toBeInTheDocument();
});
it("does not claim \"Offline translator\" for the backend's safe-defaults 'unknown'", () => {
render(<PrivacyTab info={{ translate_provider: 'unknown' }} />);
expect(screen.getByTestId('privacy-translator-unknown')).toBeInTheDocument();
expect(screen.queryByText('Offline translator')).not.toBeInTheDocument();
});
it('shows the green badge only for confirmed-offline providers', () => {
render(<PrivacyTab info={{ translate_provider: 'nllb' }} />);
expect(screen.getByText('Offline translator')).toBeInTheDocument();
expect(screen.queryByTestId('privacy-translator-unknown')).not.toBeInTheDocument();
});
it('warns for online providers and deep-links to Translation settings', () => {
render(<PrivacyTab info={{ translate_provider: 'google' }} />);
expect(screen.getByText('Translator is online: google')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('privacy-change-translator'));
expect(openSettingsTab).toHaveBeenCalledWith('translation');
});
});
@@ -4,7 +4,12 @@
* A table of user pronunciation entries (term respelling), scoped Global or to
* a language. Entries are applied as pure text substitution before synthesis, so
* a saved entry changes the audio on every engine. Plus a model-free "test"
* field that previews the substitution via POST /pronunciation/test.
* field that previews the substitution via POST /pronunciation/test with a
* language selector, since language-scoped entries only apply when the request
* carries that language. Preview requests are debounced and sequence-guarded so
* a slow earlier response can never overwrite a newer one, and the preview
* re-runs after any add/toggle/delete/import so it never shows a stale result.
* Backup & restore round-trips GET /pronunciation/export POST /pronunciation/import.
*
* Endpoints (loopback-only):
* GET /pronunciation
@@ -12,18 +17,34 @@
* PUT /pronunciation/{id} (partial)
* DELETE /pronunciation/{id}
* POST /pronunciation/test {text, language} {substituted, changed}
* GET /pronunciation/export {entries: [...]}
* POST /pronunciation/import {entries, replace}
*
* Cross-platform: identical on macOS / Windows / Linux it's a pure form over
* a text transform, no OS-specific behavior. All strings via i18n.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BookA, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { apiJson, apiFetch } from '../../api/client';
import { askConfirm } from './native';
import { SettingsSection, SettingRow, SettingsInput, SettingsToggle } from './primitives';
import { Button, Badge, Select } from '../../ui';
const TYPES = ['respelling', 'ipa', 'cmu'];
const TEST_DEBOUNCE_MS = 250;
// Trigger a browser download for a Blob (same pattern as StoriesEditor).
function downloadBlob(blob, filename, doc = document, urlApi = URL) {
const url = urlApi.createObjectURL(blob);
const a = doc.createElement('a');
a.href = url;
a.download = filename;
doc.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => urlApi.revokeObjectURL(url), 10000);
}
export default function PronunciationPanel() {
const { t } = useTranslation();
@@ -33,8 +54,17 @@ export default function PronunciationPanel() {
const [language, setLanguage] = useState('');
const [type, setType] = useState('respelling');
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
const [testText, setTestText] = useState('');
const [testLang, setTestLang] = useState('*');
const [testOut, setTestOut] = useState(null);
const [testError, setTestError] = useState(false);
// Preview sequencing: per-keystroke POSTs can resolve out of order, so each
// request takes a ticket and only the latest one may write the result.
const testSeq = useRef(0);
const testTimer = useRef(null);
const fileRef = useRef(null);
const refresh = useCallback(async () => {
setError(null);
@@ -49,6 +79,80 @@ export default function PronunciationPanel() {
refresh();
}, [refresh]);
useEffect(
() => () => {
if (testTimer.current) clearTimeout(testTimer.current);
},
[],
);
const runTest = useCallback(async (text, lang) => {
// A direct run supersedes any pending debounced run (e.g. the user picked a
// preview language while a keystroke's timer was still counting down).
if (testTimer.current) {
clearTimeout(testTimer.current);
testTimer.current = null;
}
const seq = ++testSeq.current;
if (!text.trim()) {
setTestOut(null);
setTestError(false);
return;
}
try {
const r = await apiJson('/pronunciation/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
...(lang && lang !== '*' ? { language: lang } : {}),
}),
});
if (seq === testSeq.current) {
setTestOut(r);
setTestError(false);
}
} catch {
if (seq === testSeq.current) {
setTestOut(null);
setTestError(true);
}
}
}, []);
const scheduleTest = useCallback(
(text, lang) => {
if (testTimer.current) clearTimeout(testTimer.current);
testSeq.current += 1; // invalidate any in-flight response
if (!text.trim()) {
setTestOut(null);
setTestError(false);
return;
}
testTimer.current = setTimeout(() => runTest(text, lang), TEST_DEBOUNCE_MS);
},
[runTest],
);
const onTestTextChange = (value) => {
setTestText(value);
scheduleTest(value, testLang);
};
const onTestLangChange = (value) => {
setTestLang(value);
if (testText.trim()) runTest(testText, value);
};
// After a successful mutation the dictionary changed re-run the preview so
// it reflects the new state instead of going stale.
const retest = useCallback(
(lang = testLang) => {
if (testText.trim()) runTest(testText, lang);
},
[runTest, testText, testLang],
);
const onAdd = async () => {
if (!term.trim()) return;
setError(null);
@@ -69,11 +173,19 @@ export default function PronunciationPanel() {
setLanguage('');
setType('respelling');
refresh();
retest();
} catch (e) {
setError(e?.message || t('pronunciation.save_error'));
}
};
const onAddKeyDown = (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
onAdd();
}
};
const onToggle = async (entry) => {
try {
await apiFetch(`/pronunciation/${encodeURIComponent(entry.id)}`, {
@@ -82,6 +194,7 @@ export default function PronunciationPanel() {
body: JSON.stringify({ enabled: !entry.enabled }),
});
refresh();
retest();
} catch (e) {
setError(e?.message || t('pronunciation.save_error'));
}
@@ -91,32 +204,73 @@ export default function PronunciationPanel() {
try {
await apiFetch(`/pronunciation/${encodeURIComponent(id)}`, { method: 'DELETE' });
refresh();
retest();
} catch (e) {
setError(e?.message || t('pronunciation.save_error'));
}
};
const onTest = async (value) => {
setTestText(value);
if (!value.trim()) {
setTestOut(null);
const onExport = async () => {
setError(null);
setNotice(null);
try {
const data = await apiJson('/pronunciation/export');
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
downloadBlob(blob, 'pronunciation-dictionary.json');
} catch (e) {
setError(e?.message || t('pronunciation.export_error'));
}
};
const onImportFile = async (file) => {
setError(null);
setNotice(null);
let imported;
try {
const parsed = JSON.parse(await file.text());
imported = Array.isArray(parsed) ? parsed : parsed?.entries;
if (!Array.isArray(imported)) throw new Error('not an entry list');
} catch {
setError(t('pronunciation.import_error'));
return;
}
let replace = false;
if (entries.length > 0) {
replace = await askConfirm(
t('pronunciation.import_replace_prompt', { count: entries.length }),
t('pronunciation.backup_title'),
);
}
try {
const r = await apiJson('/pronunciation/test', {
const res = await apiJson('/pronunciation/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: value }),
body: JSON.stringify({ entries: imported, replace }),
});
setTestOut(r);
} catch {
setTestOut(null);
setNotice(t('pronunciation.import_done', { count: res?.imported ?? imported.length }));
refresh();
retest();
} catch (e) {
setError(e?.message || t('pronunciation.import_error'));
}
};
const scopeLabel = (s) => (!s || s === '*' ? t('pronunciation.global') : s);
const typeLabel = (ty) => t(`pronunciation.type_${ty}`, ty);
// Preview-language choices: Global plus every language the dictionary
// actually scopes entries to (keep the current pick even if its last entry
// was just deleted, so the select never renders an unknown value).
const testLangs = [
...new Set(
entries
.map((e) => e.scope || e.language)
.filter((l) => l && l !== '*')
.concat(testLang !== '*' ? [testLang] : []),
),
].sort();
const hasScopedEnabled = entries.some((e) => e.enabled && (e.scope || e.language) !== '*');
return (
<SettingsSection icon={BookA} title={t('pronunciation.title')}>
<SettingRow title={t('pronunciation.title')} hint={t('pronunciation.help')} control={null} />
@@ -147,7 +301,7 @@ export default function PronunciationPanel() {
<SettingsToggle
checked={!!e.enabled}
onChange={() => onToggle(e)}
aria-label={t('pronunciation.enabled')}
aria-label={t('pronunciation.enable_entry', { term: e.term })}
data-testid={`pron-toggle-${e.id}`}
/>
<Badge tone="neutral">{typeLabel(e.type)}</Badge>
@@ -168,6 +322,7 @@ export default function PronunciationPanel() {
<SettingRow
title={t('pronunciation.add')}
hint={t('pronunciation.lang_label')}
align="start"
control={
<div className="flex flex-wrap items-center gap-[6px] min-w-0 max-w-full">
@@ -175,7 +330,9 @@ export default function PronunciationPanel() {
type="text"
value={term}
onChange={(ev) => setTerm(ev.target.value)}
onKeyDown={onAddKeyDown}
placeholder={t('pronunciation.term_placeholder')}
aria-label={t('pronunciation.term')}
className="flex-[1_1_120px]"
data-testid="pron-term"
/>
@@ -183,7 +340,9 @@ export default function PronunciationPanel() {
type="text"
value={replacement}
onChange={(ev) => setReplacement(ev.target.value)}
onKeyDown={onAddKeyDown}
placeholder={t('pronunciation.replacement_placeholder')}
aria-label={t('pronunciation.replacement')}
className="flex-[1_1_120px]"
data-testid="pron-replacement"
/>
@@ -191,6 +350,7 @@ export default function PronunciationPanel() {
size="sm"
value={type}
onChange={(ev) => setType(ev.target.value)}
aria-label={t('pronunciation.type')}
data-testid="pron-type"
>
{TYPES.map((ty) => (
@@ -203,11 +363,19 @@ export default function PronunciationPanel() {
type="text"
value={language}
onChange={(ev) => setLanguage(ev.target.value)}
placeholder={t('pronunciation.lang_label')}
className="w-[90px] flex-none"
onKeyDown={onAddKeyDown}
placeholder={t('pronunciation.lang_placeholder')}
aria-label={t('pronunciation.lang_label')}
className="w-[130px] flex-none"
data-testid="pron-language"
/>
<Button variant="subtle" size="sm" onClick={onAdd} data-testid="pron-add">
<Button
variant="subtle"
size="sm"
onClick={onAdd}
disabled={!term.trim()}
data-testid="pron-add"
>
{t('pronunciation.add')}
</Button>
</div>
@@ -215,15 +383,35 @@ export default function PronunciationPanel() {
/>
<SettingRow
title={t('pronunciation.test_placeholder')}
title={t('pronunciation.test_label')}
subtitle={
testLang === '*' && hasScopedEnabled ? t('pronunciation.test_global_hint') : undefined
}
control={
<SettingsInput
type="text"
value={testText}
onChange={(ev) => onTest(ev.target.value)}
placeholder={t('pronunciation.test_placeholder')}
data-testid="pron-test-input"
/>
<>
<Select
size="sm"
value={testLang}
onChange={(ev) => onTestLangChange(ev.target.value)}
aria-label={t('pronunciation.test_language')}
data-testid="pron-test-language"
>
<option value="*">{t('pronunciation.global')}</option>
{testLangs.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</Select>
<SettingsInput
type="text"
value={testText}
onChange={(ev) => onTestTextChange(ev.target.value)}
placeholder={t('pronunciation.test_placeholder')}
aria-label={t('pronunciation.test_label')}
data-testid="pron-test-input"
/>
</>
}
/>
{testOut && (
@@ -237,6 +425,49 @@ export default function PronunciationPanel() {
)}
</p>
)}
{testError && (
<p className="perfpanel__help" data-testid="pron-test-error">
{t('pronunciation.test_error')}
</p>
)}
<SettingRow
title={t('pronunciation.backup_title')}
hint={t('pronunciation.backup_hint')}
control={
<>
<Button variant="subtle" size="sm" onClick={onExport} data-testid="pron-export">
{t('pronunciation.export')}
</Button>
<Button
variant="subtle"
size="sm"
onClick={() => fileRef.current?.click()}
data-testid="pron-import"
>
{t('pronunciation.import')}
</Button>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
className="hidden"
aria-label={t('pronunciation.import')}
data-testid="pron-import-file"
onChange={(ev) => {
const f = ev.target.files?.[0];
ev.target.value = '';
if (f) onImportFile(f);
}}
/>
</>
}
/>
{notice && (
<p className="perfpanel__help" data-testid="pron-import-done">
{notice}
</p>
)}
</SettingsSection>
);
}
@@ -11,50 +11,42 @@
* GET /api/settings/dictation-refinement
* {auto, smart_cleanup, self_correction, preserve_technical, llm_ready}
* PUT /api/settings/dictation-refinement body: partial of the above flags
*
* The section shell always renders: while loading it shows a muted loading
* line, and when the initial GET fails it shows the error with a Retry button
* instead of silently disappearing from Settings (the backend may just be
* restarting). All strings go through i18n (`dictation.*`).
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Wand2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { apiJson, apiFetch } from '../../api/client';
import { useAppStore } from '../../store';
import { refineFailureNote } from './refineStatus';
import { refineFailureNoteKey } from './refineStatus';
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
import { Button } from '../../ui';
const FLAG_ROWS = [
[
'auto',
'Refine dictation with the local LLM',
'Master switch — applied to final transcripts only, never live partials. The raw transcript is always kept in History.',
],
[
'smart_cleanup',
'Remove filler words & add punctuation',
'"so um like the meeting is at 3pm you know" → "So the meeting is at 3pm."',
],
[
'self_correction',
'Apply spoken self-corrections',
'"at seven no actually six am" → "at six am"',
],
[
'preserve_technical',
'Preserve technical terms & spoken symbols',
'"index dot tsx" → "index.tsx"; identifiers stay verbatim',
],
];
// Flag key i18n label/hint pair (`dictation.flag_<key>` / `_hint`).
const FLAG_KEYS = ['auto', 'smart_cleanup', 'self_correction', 'preserve_technical'];
export default function RefinementPanel() {
const { t } = useTranslation();
const [cfg, setCfg] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
setError(null);
setLoading(true);
try {
setCfg(await apiJson('/api/settings/dictation-refinement'));
} catch (e) {
setError(e?.message || 'Failed to load refinement settings');
setError(e?.message || t('dictation.load_error'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
refresh();
@@ -71,62 +63,85 @@ export default function RefinementPanel() {
});
setCfg(await res.json());
} catch (err) {
setError(err?.message || 'Failed to save setting');
setError(err?.message || t('dictation.save_error'));
refresh();
} finally {
setSaving(false);
}
};
if (!cfg) return null;
const llmReady = Boolean(cfg.llm_ready);
const failureNote = refineFailureNote(cfg.last_refine_status);
const openLlmProviders = () => useAppStore.getState().openSettingsTab('llm-providers');
const llmReady = Boolean(cfg?.llm_ready);
const failureNoteKey = refineFailureNoteKey(cfg?.last_refine_status);
return (
<SettingsSection
icon={Wand2}
title="Dictation refinement"
description={
llmReady
? undefined
: 'Needs a local LLM endpoint — until then, raw transcripts paste unchanged.'
title={t('dictation.title')}
description={cfg && !llmReady ? t('dictation.needs_llm') : undefined}
actions={
// A first-time user with no LLM shouldn't dead-end on the description
// the configure step is one click away, before the first failure.
cfg && !llmReady ? (
<Button
variant="subtle"
size="sm"
onClick={openLlmProviders}
data-testid="refine-open-llm"
>
{t('dictation.open_llm_providers')}
</Button>
) : undefined
}
>
{error && (
<div className="perfpanel__error" role="alert">
{error}
{!cfg && (
<>
{' '}
<button
type="button"
className="underline"
onClick={refresh}
data-testid="refine-retry"
>
{t('dictation.retry')}
</button>
</>
)}
</div>
)}
{failureNote && (
{!cfg && !error && loading && <p className="perfpanel__help">{t('common.loading')}</p>}
{cfg && failureNoteKey && (
<div className="perfpanel__error" role="status">
{failureNote}{' '}
<button
type="button"
className="underline"
onClick={() => useAppStore.getState().openSettingsTab('llm-providers')}
>
Open LLM Providers
{t(failureNoteKey)}{' '}
<button type="button" className="underline" onClick={openLlmProviders}>
{t('dictation.open_llm_providers')}
</button>
</div>
)}
{FLAG_ROWS.map(([key, label, help]) => (
<SettingRow
key={key}
title={label}
subtitle={key === 'auto' && !llmReady ? 'no LLM configured' : undefined}
hint={help}
control={
<SettingsToggle
checked={Boolean(cfg[key])}
onChange={(next) => onToggle(key, next)}
disabled={saving || (key !== 'auto' && !cfg.auto)}
aria-label={label}
/>
}
/>
))}
{cfg &&
FLAG_KEYS.map((key) => (
<SettingRow
key={key}
title={t(`dictation.flag_${key}`)}
subtitle={key === 'auto' && !llmReady ? t('dictation.no_llm_configured') : undefined}
hint={t(`dictation.flag_${key}_hint`)}
control={
<SettingsToggle
checked={Boolean(cfg[key])}
onChange={(next) => onToggle(key, next)}
disabled={saving || (key !== 'auto' && !cfg.auto)}
aria-label={t(`dictation.flag_${key}`)}
/>
}
/>
))}
</SettingsSection>
);
}
@@ -7,23 +7,41 @@
* re-resolves the base. "Test" hits {url}/health (with the key) and shows
* the remote's version + device.
*
* Saving is guarded: the URL must be a parseable http(s):// URL (a typo'd
* base would brick every API call after the reload), and saving a URL that
* hasn't passed a connection test asks for confirmation first.
*
* Pairs with the backend's OMNIVOICE_API_KEY bearer gate; full recipe in
* docs/remote-gpu.md.
*/
import React, { useState } from 'react';
import { Server } from 'lucide-react';
import toast from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { LS_BACKEND_URL, LS_API_KEY, API } from '../../api/client';
import { askConfirm } from '../../utils/dialog';
import { SettingsSection, SettingRow, InfoHint, SettingsInput } from './primitives';
import { Button, Badge } from '../../ui';
import RestartBadge from './RestartBadge';
const REMOTE_GPU_DOCS_URL =
'https://github.com/debpalash/OmniVoice-Studio/blob/main/docs/remote-gpu.md';
export default function RemoteBackendPanel() {
/** A saved backend base must be a parseable absolute http(s) URL. */
export function isValidBackendUrl(value) {
if (!value) return false;
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
export default function RemoteBackendPanel({ reload = () => window.location.reload() }) {
const { t } = useTranslation();
const [url, setUrl] = useState(() => localStorage.getItem(LS_BACKEND_URL) || '');
const [key, setKey] = useState(() => localStorage.getItem(LS_API_KEY) || '');
const [probe, setProbe] = useState(null); // {ok, detail}
const [probe, setProbe] = useState(null); // {ok, detail, target}
const [testing, setTesting] = useState(false);
const normalized = url.trim().replace(/\/+$/, '');
@@ -31,48 +49,87 @@ export default function RemoteBackendPanel() {
const onTest = async () => {
setTesting(true);
setProbe(null);
const target = normalized || API;
try {
const target = normalized || API;
const res = await fetch(`${target}/health`, {
headers: key.trim() ? { Authorization: `Bearer ${key.trim()}` } : {},
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body?.detail || `HTTP ${res.status}`);
setProbe({ ok: true, detail: `${body.version || '?'} on ${body.device || '?'}` });
setProbe({
ok: true,
detail: `${body.version || '?'} on ${body.device || '?'}`,
target,
});
} catch (e) {
setProbe({ ok: false, detail: e?.message || 'unreachable' });
setProbe({
ok: false,
detail:
e?.message || t('settings.remote_backend_unreachable', { defaultValue: 'unreachable' }),
target,
});
} finally {
setTesting(false);
}
};
const onSave = () => {
if (normalized) localStorage.setItem(LS_BACKEND_URL, normalized);
else localStorage.removeItem(LS_BACKEND_URL);
const onSave = async () => {
if (normalized) {
if (!isValidBackendUrl(normalized)) {
toast.error(
t('settings.remote_backend_invalid_url', {
defaultValue:
'Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).',
}),
);
return;
}
// A wrong base bricks every API call after the reload if this exact
// URL hasn't passed a connection test, make the user confirm.
const verified = probe?.ok && probe.target === normalized;
if (!verified) {
const go = await askConfirm(
t('settings.remote_backend_confirm_unverified', {
defaultValue:
"This backend URL hasn't passed a connection test. Save it and reload anyway? " +
"If it's wrong, the app can't reach any backend until you change it back here.",
}),
t('settings.remote_backend_confirm_title', { defaultValue: 'Use unverified backend?' }),
);
if (!go) return;
}
localStorage.setItem(LS_BACKEND_URL, normalized);
} else {
localStorage.removeItem(LS_BACKEND_URL);
}
if (key.trim()) localStorage.setItem(LS_API_KEY, key.trim());
else localStorage.removeItem(LS_API_KEY);
// api/client.ts resolves the base once at module load.
window.location.reload();
reload();
};
return (
<SettingsSection
icon={Server}
title="Remote backend"
description="Run inference on another machine; leave the URL empty for the local backend."
title={t('settings.remote_backend_title', { defaultValue: 'Remote backend' })}
description={t('settings.remote_backend_desc', {
defaultValue:
'Run inference on another machine; leave the URL empty for the local backend. ' +
'Saving reloads the app to apply.',
})}
actions={
<>
<RestartBadge />
<InfoHint learnMoreHref={REMOTE_GPU_DOCS_URL}>
Start the backend on the other machine with <code>OMNIVOICE_API_KEY</code> set, reach it
over your tailnet, and point this app at it.
</InfoHint>
</>
<InfoHint learnMoreHref={REMOTE_GPU_DOCS_URL}>
<Trans
i18nKey="settings.remote_backend_hint"
defaults="Start the backend on the other machine with <1>OMNIVOICE_API_KEY</1> set, reach it over your tailnet, and point this app at it."
components={{ 1: <code /> }}
/>
</InfoHint>
}
>
<SettingRow
stack
title="Backend URL"
title={t('settings.remote_backend_url', { defaultValue: 'Backend URL' })}
control={
<SettingsInput
mono
@@ -80,19 +137,23 @@ export default function RemoteBackendPanel() {
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://gpu-box.tailnet.ts.net:3900"
aria-label={t('settings.remote_backend_url', { defaultValue: 'Backend URL' })}
data-testid="remote-backend-url"
/>
}
/>
<SettingRow
stack
title="API key"
title={t('settings.remote_backend_key', { defaultValue: 'API key' })}
control={
<SettingsInput
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="value of OMNIVOICE_API_KEY on the server"
placeholder={t('settings.remote_backend_key_placeholder', {
defaultValue: 'value of OMNIVOICE_API_KEY on the server',
})}
aria-label={t('settings.remote_backend_key', { defaultValue: 'API key' })}
data-testid="remote-backend-key"
/>
}
@@ -107,14 +168,22 @@ export default function RemoteBackendPanel() {
disabled={testing}
data-testid="remote-backend-test"
>
Test connection
{t('settings.remote_backend_test', { defaultValue: 'Test connection' })}
</Button>
<Button variant="subtle" size="sm" onClick={onSave} data-testid="remote-backend-save">
Save &amp; reload
{t('settings.remote_backend_save', { defaultValue: 'Save & reload' })}
</Button>
{probe && (
<Badge tone={probe.ok ? 'success' : 'danger'} dot role="status">
{probe.ok ? `OK — ${probe.detail}` : `Failed — ${probe.detail}`}
{probe.ok
? t('settings.remote_backend_probe_ok', {
detail: probe.detail,
defaultValue: 'OK — {{detail}}',
})
: t('settings.remote_backend_probe_fail', {
detail: probe.detail,
defaultValue: 'Failed — {{detail}}',
})}
</Badge>
)}
</div>
@@ -0,0 +1,122 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('react-hot-toast', () => ({
default: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('../../api/client', () => ({
LS_BACKEND_URL: 'ov_backend_url',
LS_API_KEY: 'ov_api_key',
API: 'http://127.0.0.1:3900',
}));
// Shared confirmation dialog (Tauri-aware) controlled per test.
const { askConfirm } = vi.hoisted(() => ({ askConfirm: vi.fn() }));
vi.mock('../../utils/dialog', () => ({ askConfirm }));
import toast from 'react-hot-toast';
import RemoteBackendPanel, { isValidBackendUrl } from './RemoteBackendPanel';
describe('isValidBackendUrl', () => {
it('accepts absolute http(s) URLs only', () => {
expect(isValidBackendUrl('http://gpu-box:3900')).toBe(true);
expect(isValidBackendUrl('https://gpu-box.tailnet.ts.net:3900')).toBe(true);
// The classic typo: schemeless host:port parses as a URL with a bogus
// protocol it must NOT be accepted (it bricks every call post-reload).
expect(isValidBackendUrl('gpu-box:3900')).toBe(false);
expect(isValidBackendUrl('not a url')).toBe(false);
expect(isValidBackendUrl('ftp://gpu-box')).toBe(false);
expect(isValidBackendUrl('')).toBe(false);
});
});
describe('RemoteBackendPanel', () => {
let reload;
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
reload = vi.fn();
});
const setUrl = (value) =>
fireEvent.change(screen.getByTestId('remote-backend-url'), { target: { value } });
const clickSave = () => fireEvent.click(screen.getByTestId('remote-backend-save'));
it('rejects an invalid URL instead of saving and reloading into a broken app', async () => {
render(<RemoteBackendPanel reload={reload} />);
setUrl('gpu-box:3900');
clickSave();
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(reload).not.toHaveBeenCalled();
expect(localStorage.getItem('ov_backend_url')).toBeNull();
expect(askConfirm).not.toHaveBeenCalled();
});
it('asks for confirmation before saving an unverified URL, and aborts on decline', async () => {
askConfirm.mockResolvedValue(false);
render(<RemoteBackendPanel reload={reload} />);
setUrl('http://gpu-box:3900');
clickSave();
await waitFor(() => expect(askConfirm).toHaveBeenCalled());
expect(reload).not.toHaveBeenCalled();
expect(localStorage.getItem('ov_backend_url')).toBeNull();
});
it('saves and reloads an unverified URL when the user confirms', async () => {
askConfirm.mockResolvedValue(true);
render(<RemoteBackendPanel reload={reload} />);
setUrl('http://gpu-box:3900/');
clickSave();
await waitFor(() => expect(reload).toHaveBeenCalled());
// Trailing slashes are normalized before persisting.
expect(localStorage.getItem('ov_backend_url')).toBe('http://gpu-box:3900');
});
it('skips the confirmation when the exact URL passed a connection test', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: '0.3.15', device: 'cuda' }),
});
render(<RemoteBackendPanel reload={reload} />);
setUrl('http://gpu-box:3900');
fireEvent.click(screen.getByTestId('remote-backend-test'));
await screen.findByText('OK — 0.3.15 on cuda');
clickSave();
await waitFor(() => expect(reload).toHaveBeenCalled());
expect(askConfirm).not.toHaveBeenCalled();
expect(localStorage.getItem('ov_backend_url')).toBe('http://gpu-box:3900');
});
it('clears both settings and reloads without confirmation when the URL is emptied', async () => {
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
localStorage.setItem('ov_api_key', 'k');
render(<RemoteBackendPanel reload={reload} />);
setUrl('');
fireEvent.change(screen.getByTestId('remote-backend-key'), { target: { value: '' } });
clickSave();
await waitFor(() => expect(reload).toHaveBeenCalled());
expect(askConfirm).not.toHaveBeenCalled();
expect(localStorage.getItem('ov_backend_url')).toBeNull();
expect(localStorage.getItem('ov_api_key')).toBeNull();
});
it('renders localized strings and labelled inputs (no hardcoded-English bypass)', () => {
render(<RemoteBackendPanel reload={reload} />);
// Strings resolve through i18n (en locale in tests)
expect(screen.getByText('Remote backend')).toBeInTheDocument();
expect(screen.getByText('Test connection')).toBeInTheDocument();
expect(screen.getByText('Save & reload')).toBeInTheDocument();
// and both inputs carry accessible names.
expect(screen.getByLabelText('Backend URL')).toBeInTheDocument();
expect(screen.getByLabelText('API key')).toBeInTheDocument();
});
});
@@ -15,16 +15,48 @@ import { GROUPS } from './settingsCategories';
* <optgroup> per group) so the whole IA stays reachable on a phone-width window.
*
* `visibleIds` (a Set) filters which categories render the search box in the
* parent drives it. Groups with no visible items are hidden entirely.
* parent drives it. Groups with no visible items are hidden entirely; when the
* search matches NOTHING, a "no results" empty state (with a clear-search
* action) replaces both layouts so the nav never renders blank.
*
* @param {Set<string>} visibleIds category ids to show (search-filtered)
* @param {string} active active category id
* @param {function} onSelect (id) => void
* @param {Set<string>} visibleIds category ids to show (search-filtered)
* @param {string} active active category id
* @param {function} onSelect (id) => void
* @param {string=} query current search query (for the empty state)
* @param {function=} onClearSearch clears the search query
*/
export default function SettingsSidebar({ visibleIds, active, onSelect }) {
export default function SettingsSidebar({ visibleIds, active, onSelect, query, onClearSearch }) {
const { t } = useTranslation();
const isVisible = (id) => !visibleIds || visibleIds.has(id);
const label = (it) => t(it.labelKey, { defaultValue: it.defaultLabel });
const anyVisible = GROUPS.some((g) => g.items.some((it) => isVisible(it.id)));
if (!anyVisible) {
return (
<nav aria-label={t('settings.title', { defaultValue: 'Settings' })}>
<div
data-testid="settings-search-empty"
className="px-[var(--space-3)] py-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[color:var(--chrome-fg-muted)]"
>
<p className="m-0 mb-[var(--space-3)]">
{t('settings.search_no_results', {
defaultValue: 'No settings match “{{query}}”',
query: query ?? '',
})}
</p>
{onClearSearch && (
<button
type="button"
onClick={onClearSearch}
className="cursor-pointer appearance-none rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] font-medium text-[color:var(--chrome-fg)] hover:text-[color:var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
>
{t('common.clear', { defaultValue: 'Clear' })}
</button>
)}
</div>
</nav>
);
}
return (
<nav aria-label={t('settings.title', { defaultValue: 'Settings' })}>
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import SettingsSidebar from './SettingsSidebar';
describe('SettingsSidebar — zero-match search empty state', () => {
it('renders a "no results" message with the query and a Clear action instead of a blank nav', () => {
const onClearSearch = vi.fn();
render(
<SettingsSidebar
visibleIds={new Set()}
active="general"
onSelect={() => {}}
query="zzz-no-such-setting"
onClearSearch={onClearSearch}
/>,
);
const empty = screen.getByTestId('settings-search-empty');
expect(empty.textContent).toContain('zzz-no-such-setting');
// Neither the (empty) narrow <select> nor any rail item renders.
expect(screen.queryByTestId('settings-nav-select')).toBeNull();
expect(screen.queryByTestId('settings-nav-general')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(onClearSearch).toHaveBeenCalledTimes(1);
});
it('renders the full grouped nav (and the narrow select) when nothing is filtered', () => {
render(<SettingsSidebar active="general" onSelect={() => {}} />);
expect(screen.queryByTestId('settings-search-empty')).toBeNull();
expect(screen.getByTestId('settings-nav-select')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-general')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-about')).toBeInTheDocument();
});
it('renders only the matching categories when a filter set is provided', () => {
render(
<SettingsSidebar
visibleIds={new Set(['network'])}
active="network"
onSelect={() => {}}
query="proxy"
onClearSearch={() => {}}
/>,
);
expect(screen.getByTestId('settings-nav-network')).toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-general')).toBeNull();
expect(screen.queryByTestId('settings-search-empty')).toBeNull();
});
});
+55 -18
View File
@@ -1,30 +1,30 @@
/**
* Settings Storage (System group).
*
* Shows where OmniVoice keeps its data and outputs (read-only, from systemInfo)
* and provides a NEW "Factory reset" action that clears the locally-persisted
* UI preferences (the zustand `omnivoice.app` localStorage blob) behind a
* Shows where OmniVoice keeps its data and outputs (read-only, from systemInfo,
* each with an Open-folder affordance via the /export/reveal endpoint) and a
* "Factory reset" action that clears every locally-persisted UI preference
* the full registry in utils/prefKeys.js, not just the zustand blob behind a
* confirm Dialog, then reloads.
*
* NOTE: the models *cache* directory lives in the Models category (StoragePanel)
* this category is about the app's own data/outputs paths and a clean-slate
* reset of UI prefs. Factory reset only touches localStorage prefs; it never
* deletes the user's voices, projects, or outputs on disk.
* deletes the user's voices, projects, or outputs on disk, and never wipes the
* remote-backend connection or dictation history (see prefKeys.PRESERVED_KEYS).
*/
import React, { useState } from 'react';
import { HardDrive, RotateCcw } from 'lucide-react';
import { FolderOpen, HardDrive, RotateCcw } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useSystemInfo } from '../../api/hooks';
import { exportReveal } from '../../api/exports';
import { clearLocalPreferences } from '../../utils/prefKeys';
import { Button, Dialog } from '../../ui';
import { SettingsSection } from './primitives';
import Row from './Row';
import HistoryRetentionPanel from './HistoryRetentionPanel';
// The zustand persist key (see store/index.ts `name`). Clearing it resets every
// persisted UI preference to its slice default on the next load.
const PREFS_LS_KEY = 'omnivoice.app';
export default function StorageTab() {
const { t } = useTranslation();
const { data: info } = useSystemInfo();
@@ -32,7 +32,7 @@ export default function StorageTab() {
const factoryReset = () => {
try {
localStorage.removeItem(PREFS_LS_KEY);
clearLocalPreferences();
toast.success(
t('settings.factory_reset_done', { defaultValue: 'Preferences cleared — reloading…' }),
);
@@ -41,11 +41,48 @@ export default function StorageTab() {
setTimeout(() => window.location.reload(), 350);
} catch (e) {
toast.error(
t('settings.factory_reset_failed', { defaultValue: 'Reset failed', message: e?.message }),
t('settings.factory_reset_failed', {
defaultValue: 'Reset failed: {{message}}',
message: e?.message || e,
}),
);
}
};
const openFolder = async (path) => {
try {
await exportReveal({ path });
} catch (e) {
toast.error(
e?.message || t('settings.open_folder_failed', { defaultValue: 'Could not open folder' }),
);
}
};
const pathRow = (label, path, testId) => (
<Row
label={label}
value={
<>
<span>{path || '—'}</span>
{path && (
<Button
variant="ghost"
size="sm"
leading={<FolderOpen size={12} />}
onClick={() => openFolder(path)}
title={path}
data-testid={testId}
>
{t('settings.storage_open_folder', { defaultValue: 'Open folder' })}
</Button>
)}
</>
}
mono
/>
);
return (
<>
<SettingsSection
@@ -55,13 +92,13 @@ export default function StorageTab() {
defaultValue: 'Where OmniVoice keeps your data and outputs.',
})}
>
<Row
label={t('privacy.uploads_at')}
value={info?.data_dir ? `${info.data_dir}/` : ''}
mono
/>
<Row label={t('privacy.outputs_at')} value={info?.outputs_dir || ''} mono />
<Row label={t('about.crash_log')} value={info?.crash_log_path || ''} mono />
{pathRow(
t('settings.data_dir_at', { defaultValue: 'App data stored at' }),
info?.data_dir ? `${info.data_dir}/` : '',
'storage-open-data-dir',
)}
{pathRow(t('privacy.outputs_at'), info?.outputs_dir || '', 'storage-open-outputs-dir')}
{pathRow(t('about.crash_log'), info?.crash_log_path || '', 'storage-open-crash-log')}
</SettingsSection>
<HistoryRetentionPanel />
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import StorageTab from './StorageTab';
const INFO = {
data_dir: '/home/u/.omnivoice',
outputs_dir: '/home/u/.omnivoice/outputs',
crash_log_path: '/home/u/.omnivoice/crash_log.txt',
};
/** URL-aware fetch mock: /system/info + history-retention + export/reveal. */
function mockFetch() {
const fn = vi.fn(async (url) => {
const body = /\/system\/info/.test(url)
? INFO
: /history-retention/.test(url)
? { cap: 200, default: 200 }
: { success: true };
return {
ok: true,
status: 200,
json: async () => body,
text: async () => JSON.stringify(body),
};
});
global.fetch = fn;
return fn;
}
function renderTab() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<StorageTab />
</QueryClientProvider>,
);
}
describe('StorageTab', () => {
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it('labels the data dir as app data (not uploads) and offers Open folder on every path', async () => {
mockFetch();
renderTab();
await waitFor(() => expect(screen.getByText(`${INFO.data_dir}/`)).toBeInTheDocument());
expect(screen.getByText('App data stored at')).toBeInTheDocument();
expect(screen.getByTestId('storage-open-data-dir')).toBeInTheDocument();
expect(screen.getByTestId('storage-open-outputs-dir')).toBeInTheDocument();
expect(screen.getByTestId('storage-open-crash-log')).toBeInTheDocument();
});
it('Open folder reveals the path via /export/reveal', async () => {
const fetchMock = mockFetch();
renderTab();
const btn = await screen.findByTestId('storage-open-outputs-dir');
fireEvent.click(btn);
await waitFor(() => {
const call = fetchMock.mock.calls.find(([u]) => u.endsWith('/export/reveal'));
expect(call).toBeTruthy();
expect(JSON.parse(call[1].body)).toEqual({ path: INFO.outputs_dir });
});
});
it('factory reset clears every registered preference key, not just the zustand blob', async () => {
mockFetch();
// Preferences scattered across the app (the pre-registry bug left these behind):
localStorage.setItem('omnivoice.app', '{"state":{}}');
localStorage.setItem('omnivoice.navRailSide', 'right');
localStorage.setItem('omnivoice.settings.category', 'storage');
localStorage.setItem('omni_capture_live_typing', '1');
localStorage.setItem('ov_stories_global_speed', '1.4');
localStorage.setItem('omni_ui', '{"uiScale":1.2}');
localStorage.setItem('dismissed_lang_suggestion', 'true');
// User data + connection state that must survive a reset:
localStorage.setItem('omni_transcriptions', '[{"text":"note"}]');
localStorage.setItem('ov_backend_url', 'http://192.168.1.4:7842');
renderTab();
await waitFor(() => expect(screen.getByTestId('factory-reset-open')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('factory-reset-open'));
await waitFor(() => expect(screen.getByTestId('factory-reset-confirm')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('factory-reset-confirm'));
await waitFor(() => expect(localStorage.getItem('omnivoice.app')).toBeNull());
expect(localStorage.getItem('omnivoice.navRailSide')).toBeNull();
expect(localStorage.getItem('omnivoice.settings.category')).toBeNull();
expect(localStorage.getItem('omni_capture_live_typing')).toBeNull();
expect(localStorage.getItem('ov_stories_global_speed')).toBeNull();
expect(localStorage.getItem('omni_ui')).toBeNull();
expect(localStorage.getItem('dismissed_lang_suggestion')).toBeNull();
// Never touch user data or the remote-backend connection:
expect(localStorage.getItem('omni_transcriptions')).toBe('[{"text":"note"}]');
expect(localStorage.getItem('ov_backend_url')).toBe('http://192.168.1.4:7842');
});
});
@@ -21,6 +21,7 @@ import { exportReveal } from '../../api/exports';
import { clearSystemLogs } from '../../api/system';
import { useAppStore } from '../../store';
import { fmtBytes } from './models/format';
import { askConfirm } from './native';
import { SettingsSection } from './primitives';
// Once-per-session guard for the out-of-Settings critical toast.
@@ -137,7 +138,10 @@ export default function StorageUsagePanel() {
toast.error(warningText(t, critical), { id: 'storage-critical', duration: 8000 });
}
} catch (e) {
setError(e?.message || 'Failed to load storage info');
setError(
e?.message ||
t('settings.storage_load_failed', { defaultValue: 'Failed to load storage info' }),
);
} finally {
setLoading(false);
setRefreshing(false);
@@ -154,17 +158,58 @@ export default function StorageUsagePanel() {
try {
await exportReveal({ path });
} catch (e) {
toast.error(e?.message || 'Could not open folder');
toast.error(
e?.message || t('settings.open_folder_failed', { defaultValue: 'Could not open folder' }),
);
}
};
// Same confirm gate as Settings Logs Clear: this truncates the crash log
// too (the primary bug-report artifact), so it must never be one stray click.
const clearLogs = async () => {
const ok = await askConfirm(
t('settings.clear_backend_confirm', {
defaultValue: 'Clear the backend runtime + crash logs? This cannot be undone.',
}),
t('settings.clear_backend_title', { defaultValue: 'Clear logs' }),
);
if (!ok) return;
try {
await clearSystemLogs();
toast.success(t('settings.storage_logs_cleared', { defaultValue: 'Logs cleared' }));
load(true);
} catch (e) {
toast.error(e?.message || 'Could not clear logs');
toast.error(
e?.message || t('settings.clear_backend_failed', { defaultValue: 'Failed to clear logs' }),
);
}
};
const clearTemp = async () => {
const ok = await askConfirm(
t('settings.storage_clear_temp_confirm', {
defaultValue:
"Delete OmniVoice's temporary working files? Don't do this while a dub or batch job is running.",
}),
t('settings.storage_clear_temp', { defaultValue: 'Clear temp files' }),
);
if (!ok) return;
try {
const r = await apiJson('/api/settings/storage/temp/clear', { method: 'POST' });
toast.success(
t('settings.storage_temp_cleared', {
defaultValue: 'Temporary files cleared — {{freed}} freed',
freed: fmtBytes(r?.freed_bytes || 0),
}),
);
load(true);
} catch (e) {
toast.error(
e?.message ||
t('settings.storage_clear_temp_failed', {
defaultValue: 'Could not clear temporary files',
}),
);
}
};
@@ -313,6 +358,19 @@ export default function StorageUsagePanel() {
{t('settings.storage_manage_models', { defaultValue: 'Manage models' })}
</SmallButton>
)}
{cat.id === 'temp' && (
<SmallButton
onClick={clearTemp}
title={t('settings.storage_clear_temp_hint', {
defaultValue: "Delete OmniVoice's own files in the temp directory",
})}
testId="storage-clear-temp"
disabled={(cat.bytes || 0) === 0}
>
<Trash2 size={12} />
{t('settings.storage_clear_temp', { defaultValue: 'Clear temp files' })}
</SmallButton>
)}
{cat.exists && (
<SmallButton
onClick={() => openFolder(cat.path)}
@@ -164,6 +164,65 @@ describe('StorageUsagePanel', () => {
});
});
it('Clear logs is confirm-gated: declining leaves the logs untouched', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
const fetchMock = mockFetchWith(REPORT);
render(<StorageUsagePanel />);
await waitFor(() => expect(screen.getByTestId('storage-clear-logs')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('storage-clear-logs'));
await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
expect(confirmSpy.mock.calls[0][0]).toMatch(/cannot be undone/i);
expect(fetchMock.mock.calls.filter(([u]) => /\/system\/logs\/clear/.test(u))).toHaveLength(0);
});
it('Clear logs POSTs after the user confirms', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const fetchMock = mockFetchWith(REPORT);
render(<StorageUsagePanel />);
await waitFor(() => expect(screen.getByTestId('storage-clear-logs')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('storage-clear-logs'));
await waitFor(() => {
const call = fetchMock.mock.calls.find(([u]) => /\/system\/logs\/clear/.test(u));
expect(call).toBeTruthy();
expect(call[1]?.method).toBe('POST');
});
});
it('Clear temp files is confirm-gated and hits the temp-clear endpoint', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const report = {
...REPORT,
categories: REPORT.categories.map((c) =>
c.id === 'temp' ? { ...c, bytes: 512 * 1024 ** 2 } : c,
),
};
const fetchMock = mockFetchWith(report);
render(<StorageUsagePanel />);
await waitFor(() => expect(screen.getByTestId('storage-clear-temp')).toBeInTheDocument());
expect(screen.getByTestId('storage-clear-temp')).not.toBeDisabled();
fireEvent.click(screen.getByTestId('storage-clear-temp'));
await waitFor(() => {
const call = fetchMock.mock.calls.find(([u]) =>
u.endsWith('/api/settings/storage/temp/clear'),
);
expect(call).toBeTruthy();
expect(call[1]?.method).toBe('POST');
});
});
it('Clear temp files is disabled when there is nothing to reclaim', async () => {
mockFetchWith(REPORT); // REPORT's temp category is 0 bytes
render(<StorageUsagePanel />);
await waitFor(() => expect(screen.getByTestId('storage-clear-temp')).toBeInTheDocument());
expect(screen.getByTestId('storage-clear-temp')).toBeDisabled();
});
it('shows the load error state when the endpoint fails', async () => {
// An HTTP error (not a transport failure) apiFetch surfaces it without retrying.
global.fetch = vi.fn().mockResolvedValue({
@@ -1,6 +1,6 @@
import React from 'react';
import { flexRender } from '@tanstack/react-table';
import { Table } from '../../../ui';
import { Button, Table } from '../../../ui';
/**
* Virtualized model table view. Purely presentational the table instance,
@@ -14,6 +14,9 @@ export default function ModelsTable({
tableBodyRef,
getRowRuntime,
t,
// Optional: when provided, the "no matches" empty state offers a one-click
// way back to the full list instead of a dead end.
onClearFilters,
}) {
return (
<Table className="models-table">
@@ -93,7 +96,20 @@ export default function ModelsTable({
);
})}
{tableRows.length === 0 && (
<div className="models-table__empty">{t('models.no_matches')}</div>
<div className="models-table__empty">
<span>{t('models.no_matches')}</span>
{onClearFilters && (
<Button
size="sm"
variant="subtle"
className="ml-[8px]"
onClick={onClearFilters}
data-testid="models-clear-filters"
>
{t('models.clear_filters')}
</Button>
)}
</div>
)}
</div>
</div>
@@ -15,6 +15,9 @@ export default function RecoBanner({
installingReco,
setInstallingReco,
onInstallRecommended,
// Free space (GB) on the model-cache volume, from GET /models gives the
// download buttons context and warns BEFORE a doomed multi-GB download.
diskFreeGb = null,
}) {
if (!reco) return null;
if (reco.all_installed) {
@@ -79,6 +82,28 @@ export default function RecoBanner({
</Button>
</div>
</div>
{/* Disk context next to the download actions: how much room the
download has, and a plain warning when it won't fit. */}
{diskFreeGb != null && (
<div
className="-mt-[2px] text-[length:var(--text-2xs)] text-[var(--chrome-fg-dim)]"
data-testid="reco-disk-context"
>
{t('models.reco_disk_free', { free: diskFreeGb })}
</div>
)}
{diskFreeGb != null && Number(reco.download_gb_remaining) > Number(diskFreeGb) && (
<div
role="alert"
className="text-[length:var(--text-xs)] text-[var(--chrome-severity-warn)]"
data-testid="reco-low-disk"
>
{t('models.reco_low_disk', {
need: reco.download_gb_remaining,
free: diskFreeGb,
})}
</div>
)}
<div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-0 text-[length:var(--text-sm)] leading-[1.6]">
{reco.models.map((m) => (
<span
@@ -21,6 +21,11 @@ export function makeModelColumns({
onReinstall,
onCancel,
onDismissError,
// Residency (optional): repo_id its /model/loaded entry when that model
// is resident in memory right now, else null/undefined. Legacy callers that
// don't pass it render exactly as before.
getResidency,
onUnload,
}) {
return [
{
@@ -34,7 +39,9 @@ export function makeModelColumns({
const rt = getRowRuntime(m);
return (
<>
<span className="models-row__title">
{/* Both lines truncate with ellipsis (CSS) the full text stays
reachable via the title attributes. */}
<span className="models-row__title" title={m.label}>
<span
className="models-row__avatar"
style={{ background: orgColor(m.repo_id) }}
@@ -45,7 +52,10 @@ export function makeModelColumns({
{m.label}
{m.required && <span className="models-row__tag">{t('models.required_tag')}</span>}
</span>
<span className="models-row__repo">
<span
className="models-row__repo"
title={m.note ? `${m.repo_id} · ${m.note}` : m.repo_id}
>
<code>{m.repo_id}</code>
{m.note && <span className="models-row__note"> · {m.note}</span>}
</span>
@@ -236,9 +246,27 @@ export function makeModelColumns({
<RefreshCw size={10} className="spinner" /> {t('models.working')}
</Badge>
) : m.installed ? (
<Badge tone="success" size="xs">
{t('models.installed')}
</Badge>
getResidency?.(m) ? (
// Installed AND resident in memory right now (/model/loaded
// checkpoint match). Named so users know unloading is safe.
<span className="inline-flex flex-col items-center gap-[3px]">
<Badge tone="success" size="xs">
{t('models.installed')}
</Badge>
<Badge
tone="info"
size="xs"
title={t('models.in_memory_title')}
data-testid={`model-resident-${m.repo_id}`}
>
{t('models.in_memory')}
</Badge>
</span>
) : (
<Badge tone="success" size="xs">
{t('models.installed')}
</Badge>
)
) : m.incomplete ? (
// A truncated download (backend `incomplete`): config/tokenizer landed
// but the weight shard didn't, so it still occupies disk yet can't be
@@ -324,6 +352,23 @@ export function makeModelColumns({
{t('models.cancel_btn')}
</Button>
)}
{/* Free the memory this model occupies right now. Only when the
/model/loaded entry says it's unloadable it reloads lazily
on next use, so this never loses data. */}
{(() => {
const resident = getResidency?.(m);
return resident?.unloadable && !rt.rowBusy && !rt.isDeleting && onUnload ? (
<Button
variant="subtle"
size="sm"
onClick={() => onUnload(m.repo_id)}
title={t('models.in_memory_title')}
aria-label={t('models.unload_aria', { repoId: m.repo_id })}
>
{t('models.unload_btn')}
</Button>
) : null;
})()}
{m.installed && !rt.rowBusy && !rt.isDeleting && (
<>
<Button
@@ -8,12 +8,14 @@
* The dictation final is never blocked (the hard refine timeout inserts the raw
* text regardless), so this message is purely informational.
*
* Returns the user-facing string, or null when the last refinement succeeded /
* hasn't run.
* Returns the i18n key of the user-facing message (resolve with `t(key)`), or
* null when the last refinement succeeded / hasn't run. Returning a key
* rather than a hardcoded English sentence keeps the note translatable like
* every other UI string.
*/
export function refineFailureNote(status) {
export function refineFailureNoteKey(status) {
if (!status || status.ok !== false) return null;
return status.reason === 'timeout'
? 'The last dictation refinement timed out — the LLM endpoint is slow or unreachable. Dictation still works (the raw transcript is inserted). Test the connection in LLM Providers.'
: 'The last dictation refinement failed — the configured LLM endpoint rejected the request. Dictation still works (the raw transcript is inserted). Test the connection in LLM Providers.';
? 'dictation.refine_timeout_note'
: 'dictation.refine_failed_note';
}
@@ -9,9 +9,13 @@
*
* Keep this declarative no JSX panels here (those need props/hooks and live
* in Settings.jsx's renderCategory switch). `keywords` powers the bonus
* "search matches a setting → jump to its category" behaviour.
* "search matches a setting → jump to its category" behaviour; `keywordKeys`
* lists i18n keys of prominent setting-row titles so the same search works in
* every UI language (the translated titles are matched at query time no
* separate keyword translations to maintain).
*/
import {
AudioLines,
Palette,
Settings2,
Plug,
@@ -54,6 +58,13 @@ export const GROUPS = [
'header live stats',
'system metrics',
],
keywordKeys: [
'settings.ui_scale',
'settings.color_theme',
'settings.font',
'settings.autoplay_preview',
'settings.header_live_stats',
],
},
{
id: 'general',
@@ -61,6 +72,7 @@ export const GROUPS = [
defaultLabel: 'General',
icon: Settings2,
keywords: ['language', 'locale', 'interface language', 'review mode', 'stage checkpoints'],
keywordKeys: ['settings.language', 'settings.review_mode'],
},
],
},
@@ -106,6 +118,7 @@ export const GROUPS = [
'microphone',
'voice capture',
],
keywordKeys: ['settings.shortcut'],
},
{
id: 'pronunciation',
@@ -129,6 +142,7 @@ export const GROUPS = [
'openai',
'api key',
],
keywordKeys: ['settings.translate_quality', 'settings.translation_providers'],
},
],
},
@@ -173,13 +187,38 @@ export const GROUPS = [
'temp files',
'clear logs',
],
keywordKeys: ['settings.storage_usage', 'settings.factory_reset'],
},
{
id: 'network',
labelKey: 'settings.network',
defaultLabel: 'Network',
icon: Wifi,
keywords: ['network', 'proxy', 'http proxy', 'socks', 'ffmpeg', 'ffmpeg path'],
// Only the proxy lives here now (applies immediately) the
// restart-bound FFmpeg override moved to Audio tools below.
keywords: ['network', 'proxy', 'http proxy', 'socks'],
keywordKeys: ['settings.proxy'],
},
{
id: 'audio-tools',
labelKey: 'settings.audio_tools',
defaultLabel: 'Audio tools',
icon: AudioLines,
// yt-dlp updates land in an overlay read at process start (the row
// renders RestartBadge) lockstep-guarded in
// settingsCategories.test.jsx like Models / Performance / Sharing.
restart: true,
keywords: [
'ffmpeg',
'ffprobe',
'ffmpeg path',
'yt-dlp',
'ytdlp',
'media engine',
'video downloader',
'bundled binaries',
],
keywordKeys: ['settings.audio_tools', 'settings.ffmpeg', 'settings.audio_tools_ytdlp'],
},
{
id: 'sharing',
@@ -202,6 +241,7 @@ export const GROUPS = [
defaultLabel: 'Credentials',
icon: KeyRound,
keywords: ['credentials', 'hugging face token', 'hf token', 'api key', 'secret'],
keywordKeys: ['settings.hf_token_title'],
},
{
id: 'llm-providers',
@@ -221,6 +261,7 @@ export const GROUPS = [
'autofit',
'translation quality',
],
keywordKeys: ['settings.llmp_provider', 'settings.llmp_api_key', 'settings.llmp_model'],
},
{
id: 'llm-skills',
@@ -276,6 +317,7 @@ export const GROUPS = [
defaultLabel: 'About',
icon: Info,
keywords: ['about', 'version', 'license', 'diagnostics', 'self check'],
keywordKeys: ['about.version', 'about.self_check'],
},
],
},
@@ -310,13 +352,22 @@ export function resolveCategoryId(id) {
* Given a lowercased query, return the set of category ids whose label OR any
* keyword matches. Used to filter the sidebar and to power "search a setting
* jump to its category".
*
* @param {string} query
* @param {function=} labelFor (category) => translated label
* @param {function=} translate i18n `t` lets `keywordKeys` (setting-row
* title keys) match in the active UI language, so a German user finds
* Appearance by "Schriftart" just like an English user finds it by "font".
* The English `keywords` always match too, in every locale.
*/
export function matchCategories(query, labelFor) {
export function matchCategories(query, labelFor, translate) {
const q = query.trim().toLowerCase();
if (!q) return CATEGORIES.map((c) => c.id);
return CATEGORIES.filter((c) => {
const label = (labelFor ? labelFor(c) : c.defaultLabel).toLowerCase();
if (label.includes(q)) return true;
return (c.keywords || []).some((k) => k.toLowerCase().includes(q));
if ((c.keywords || []).some((k) => k.toLowerCase().includes(q))) return true;
if (!translate) return false;
return (c.keywordKeys || []).some((key) => String(translate(key)).toLowerCase().includes(q));
}).map((c) => c.id);
}
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CATEGORIES, CATEGORY_BY_ID, matchCategories } from './settingsCategories';
import en from '../../i18n/locales/en.json';
const SETTINGS_DIR = path.dirname(fileURLToPath(import.meta.url));
describe('matchCategories — search matching', () => {
it('an empty query returns every category', () => {
expect(matchCategories('')).toEqual(CATEGORIES.map((c) => c.id));
});
it('a query matching nothing returns an empty list (drives the sidebar empty state)', () => {
expect(matchCategories('zzz-no-such-setting')).toEqual([]);
});
it('English keywords match in every locale (no translate fn needed)', () => {
expect(matchCategories('proxy')).toContain('network');
expect(matchCategories('ui scale')).toContain('appearance');
});
it('keywordKeys match through the active locale, so localized setting names find their category', () => {
// Simulate a German UI: settings.font resolves to "Schriftart".
const t = (key) => (key === 'settings.font' ? 'Schriftart' : key);
expect(matchCategories('schriftart', undefined, t)).toContain('appearance');
// English keywords keep working alongside the translated titles.
expect(matchCategories('font', undefined, t)).toContain('appearance');
});
it('every keywordKey points at a real en.json string (typo guard)', () => {
for (const c of CATEGORIES) {
for (const key of c.keywordKeys || []) {
const value = key.split('.').reduce((node, part) => node?.[part], en);
expect(typeof value, `${c.id}: ${key} missing from en.json`).toBe('string');
}
}
});
});
describe('restart flag ↔ RestartBadge lockstep', () => {
// Panel file hosting category (per Settings.jsx renderCategory). Any panel
// that renders the "Restart required" badge must live in a category flagged
// restart: true, or the sidebar glyph / header badge contract breaks
// (that drift is exactly how Network shipped without its glyph).
const PANEL_CATEGORY = {
'StoragePanel.jsx': 'models',
'HFMirrorPanel.jsx': 'models',
'RemoteBackendPanel.jsx': 'sharing',
'AudioToolsPanel.jsx': 'audio-tools',
'PerformancePanel.jsx': 'performance',
};
const panelsUsingRestartBadge = fs
.readdirSync(SETTINGS_DIR)
.filter((f) => f.endsWith('.jsx') && !f.includes('.test.') && f !== 'RestartBadge.jsx')
.filter((f) => {
const src = fs.readFileSync(path.join(SETTINGS_DIR, f), 'utf8');
// Only the restart-warning form counts; `<RestartBadge applies` is the
// "Applies now" affordance and needs no category flag.
return /<RestartBadge(?!\s+applies)/.test(src);
});
it('finds the known restart-badge panels (scan sanity check)', () => {
expect(panelsUsingRestartBadge.length).toBeGreaterThan(0);
});
it.each(panelsUsingRestartBadge)('%s belongs to a restart-flagged category', (file) => {
const categoryId = PANEL_CATEGORY[file];
expect(
categoryId,
`${file} renders <RestartBadge /> but has no category mapping — add it to PANEL_CATEGORY and flag the category`,
).toBeDefined();
expect(
CATEGORY_BY_ID[categoryId]?.restart,
`category "${categoryId}" hosts ${file} (restart-bound setting) but lacks restart: true in settingsCategories.jsx`,
).toBe(true);
});
});
+19 -1
View File
@@ -14,6 +14,24 @@ import { mergeDescribedAttrs } from '../utils/voiceInstruct';
* Encapsulates all data-loading effects, localStorage persistence,
* real-time WebSocket updates, and model-status pill management.
*/
// Dub steps that describe live, in-process work ('uploading', 'transcribing',
// 'generating', 'stopping') must never be restored across an app restart: the
// task they referred to died with the process, so rehydrating one leaves the
// Dub tab waiting forever on progress that will never arrive (blank pane +
// eternal spinner — the "stuck on dubbing since I updated" reports, and a
// reinstall doesn't clear the webview's localStorage). Only settled states
// come back.
const STABLE_DUB_STEPS = new Set(['idle', 'editing', 'done']);
/** Clamp a persisted dubStep to a state that is valid after a cold start.
* Stable steps pass through; transient (and unknown/corrupt) values fall
* back to 'editing' when the session has segments to show, else 'idle'. */
export function clampRestoredDubStep(savedStep, savedSegments) {
if (STABLE_DUB_STEPS.has(savedStep)) return savedStep;
return Array.isArray(savedSegments) && savedSegments.length > 0 ? 'editing' : 'idle';
}
export default function useAppData() {
const mode = useAppStore((s) => s.mode);
const setMode = useAppStore((s) => s.setMode);
@@ -194,7 +212,7 @@ export default function useAppData() {
if (saved.dubLang) setDubLang(saved.dubLang);
if (saved.dubLangCode) setDubLangCode(saved.dubLangCode);
if (saved.dubTracks) setDubTracks(saved.dubTracks);
if (saved.dubStep) setDubStep(saved.dubStep);
if (saved.dubStep) setDubStep(clampRestoredDubStep(saved.dubStep, saved.dubSegments));
if (saved.dubTranscript) setDubTranscript(saved.dubTranscript);
if (saved.exportTracks) setExportTracks(saved.exportTracks);
if (saved.preserveBg !== undefined) setPreserveBg(saved.preserveBg);
+14 -1
View File
@@ -75,12 +75,25 @@ i18n
},
});
// Keep the document's text direction and language in sync with the active
// locale — without this, RTL locales (Arabic today, any future RTL addition)
// render in an LTR layout and screen readers get the wrong language.
function applyDocumentDirection(lng: string): void {
if (typeof document === 'undefined') return;
document.documentElement.dir = i18n.dir(lng);
document.documentElement.lang = lng;
}
// Fetch the bundle whenever a non-English language becomes active — covers
// both the initial browser-detected language and later picker switches.
i18n.on('languageChanged', (lng) => {
applyDocumentDirection(lng);
void loadLocale(lng);
});
if (i18n.language && i18n.language !== 'en') void loadLocale(i18n.language);
if (i18n.language) {
applyDocumentDirection(i18n.language);
if (i18n.language !== 'en') void loadLocale(i18n.language);
}
// Selectable UI languages. Native language names live here, in the i18n
// layer — never hardcoded in component code (see the "no hardcoded
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "الإعدادات",
"ui_scale": "مقياس واجهة المستخدم",
"proxy": "وكيل",
"proxy_desc": "وكيل HTTP/SOCKS5 للتنزيلات (yt-dlp، HuggingFace). يدعم http:// وhttps:// وsocks5://. يلزم إعادة التشغيل إذا تم تغييره بعد بدء الواجهة الخلفية.",
"proxy_saved": "مجموعة الوكيل - سوف يستخدمها yt-dlp للتنزيلات",
"proxy_desc": "وكيل HTTP/SOCKS5 للتنزيلات (تنزيلات الفيديو، Hugging Face). يدعم http:// وhttps:// وsocks5://. يُطبَّق على التنزيلات الجديدة فورًا.",
"proxy_saved": "تم ضبط الوكيل ستستخدمه التنزيلات الجديدة",
"proxy_save_failed": "فشل حفظ الوكيل",
"proxy_cleared": "تم مسح الوكيل",
"proxy_clear": "واضح",
@@ -153,6 +153,8 @@
"translation_providers": "مقدمو خدمات الترجمة",
"translation_providers_desc": "مفاتيح واجهة برمجة التطبيقات (API) للمترجمين عبر الإنترنت، مُعيَّنة لهذه الجلسة.",
"review_mode": "وضع المراجعة",
"review_mode_on": "مراجعة",
"review_mode_off": "سريع النيران",
"review_mode_desc": "توقف مؤقتًا بين مراحل التدفق حتى تتمكن من مراجعة مخرجات ASR/الترجمة.",
"header_live_stats": "عرض مقاييس النظام المباشرة في الرأس",
"header_live_stats_desc": "يضيف شاشة RAM / CPU / VRAM مباشرة إلى الشريط العلوي (يتم إيقاف تشغيله افتراضيًا).",
@@ -465,7 +467,6 @@
"check_updates": "التحقق من وجود تحديثات",
"copy_diagnostics": "نسخ التشخيص",
"github": "أومنيفويس على جيثب",
"model_card": "البطاقة النموذجية",
"commercial_license": "رخصة تجارية",
"update_channel": "قناة التحديث",
"channel_stable": "مستقر",
@@ -527,8 +528,6 @@
"group_microsoft": "نقطة النهاية المخصصة لمترجم مايكروسوفت"
},
"engines": {
"review_on": "مراجعة",
"review_off": "سريع النيران",
"banners_on": "لافتات المرحلة على",
"banners_off": "لافتات المرحلة قبالة",
"backend": "الخلفية",
@@ -1997,14 +1996,27 @@
"enabled": "ممكّن",
"add": "إضافة إدخال",
"remove": "إزالة {{term}}",
"empty": "لا توجد إدخالات حتى الآن. أضف واحدًا أعلاه لإصلاح كلمة تم نطقها بشكل خاطئ.",
"empty": "لا توجد إدخالات حتى الآن. أضف واحدًا أدناه لإصلاح كلمة تم نطقها بشكل خاطئ.",
"test_label": "اختبار جملة",
"test_placeholder": "اكتب جملة لمعاينة الاستبدال...",
"test_result": "سيتم التحدث على النحو التالي:",
"test_nochange": "لا توجد إدخالات متطابقة - منطوقة كما هي مكتوبة.",
"load_error": "تعذر تحميل قاموس النطق.",
"save_error": "تعذر حفظ هذا الإدخال.",
"lang_label": "رمز اللغة (على سبيل المثال، en، de) أو اتركه فارغًا لـ Global"
"lang_label": "رمز اللغة (على سبيل المثال، en، de) أو اتركه فارغًا لـ Global",
"enable_entry": "تفعيل {{term}}",
"lang_placeholder": "en, de… (فارغ = عالمي)",
"test_language": "لغة المعاينة",
"test_global_hint": "تتم معاينة الإدخالات العالمية فقط — اختر لغة لتضمين الإدخالات الخاصة بلغة معينة.",
"test_error": "المعاينة غير متاحة — تعذر الوصول إلى الخادم الخلفي.",
"backup_title": "النسخ الاحتياطي والاستعادة",
"backup_hint": "نزّل القاموس كملف JSON، أو استورد إدخالات من تصدير سابق.",
"export": "تصدير JSON",
"import": "استيراد JSON",
"import_replace_prompt": "هل تريد استبدال الإدخالات الحالية وعددها {{count}} بالملف المستورد؟ اختر لا لإضافة الإدخالات المستوردة إلى جانب الحالية.",
"import_done": "تم استيراد {{count}} من الإدخالات.",
"import_error": "تعذر استيراد هذا الملف — المتوقع هو JSON من تصدير النطق.",
"export_error": "تعذر تصدير القاموس."
},
"audiobook": {
"title": "كتاب مسموع",
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "Einstellungen",
"ui_scale": "UI-Skala",
"proxy": "Stellvertreter",
"proxy_desc": "HTTP/SOCKS5-Proxy für Downloads (yt-dlp, HuggingFace). Unterstützt http://, https://, sock5://. Bei Änderung nach dem Backend-Start ist ein Neustart erforderlich.",
"proxy_saved": "Proxy-Satz yt-dlp wird ihn für Downloads verwenden",
"proxy_desc": "HTTP/SOCKS5-Proxy für Downloads (Video-Downloads, Hugging Face). Unterstützt http://, https://, socks5://. Gilt sofort für neue Downloads.",
"proxy_saved": "Proxy gesetzt — neue Downloads verwenden ihn",
"proxy_save_failed": "Proxy konnte nicht gespeichert werden",
"proxy_cleared": "Proxy gelöscht",
"proxy_clear": "Klar",
@@ -153,6 +153,8 @@
"translation_providers": "Übersetzungsanbieter",
"translation_providers_desc": "API-Schlüssel für Online-Übersetzer, festgelegt für diese Sitzung.",
"review_mode": "Überprüfungsmodus",
"review_mode_on": "Rezension",
"review_mode_off": "Schnellfeuer",
"review_mode_desc": "Machen Sie eine Pause zwischen den Pipeline-Stufen, damit Sie die ASR-/Übersetzungsausgabe überprüfen können.",
"header_live_stats": "Live-Systemmetriken in der Kopfzeile anzeigen",
"header_live_stats_desc": "Fügt der oberen Leiste einen Live-RAM-/CPU-/VRAM-Monitor hinzu (standardmäßig deaktiviert).",
@@ -465,7 +467,6 @@
"check_updates": "Suchen Sie nach Updates",
"copy_diagnostics": "Diagnose kopieren",
"github": "OmniVoice auf GitHub",
"model_card": "Modellkarte",
"commercial_license": "Kommerzielle Lizenz",
"update_channel": "Update-Kanal",
"channel_stable": "Stabil",
@@ -527,8 +528,6 @@
"group_microsoft": "Benutzerdefinierter Microsoft Translator-Endpunkt"
},
"engines": {
"review_on": "Rezension",
"review_off": "Schnellfeuer",
"banners_on": "Bühnenbanner auf",
"banners_off": "Bühnenbanner abgehängt",
"backend": "Backend",
@@ -1997,14 +1996,27 @@
"enabled": "Aktiviert",
"add": "Eintrag hinzufügen",
"remove": "{{term}} entfernen",
"empty": "Noch keine Einträge. Fügen Sie oben eines hinzu, um ein falsch ausgesprochenes Wort zu korrigieren.",
"empty": "Noch keine Einträge. Fügen Sie unten einen hinzu, um ein falsch ausgesprochenes Wort zu korrigieren.",
"test_label": "Testen Sie einen Satz",
"test_placeholder": "Geben Sie einen Satz ein, um eine Vorschau der Ersetzung anzuzeigen ...",
"test_result": "Wird gesprochen als:",
"test_nochange": "Keine Einträge stimmen überein gesprochen wie geschrieben.",
"load_error": "Das Aussprachewörterbuch konnte nicht geladen werden.",
"save_error": "Dieser Eintrag konnte nicht gespeichert werden.",
"lang_label": "Sprachcode (z. B. en, de) oder bei Global leer lassen"
"lang_label": "Sprachcode (z. B. en, de) oder bei Global leer lassen",
"enable_entry": "{{term}} aktivieren",
"lang_placeholder": "en, de… (leer = Global)",
"test_language": "Vorschausprache",
"test_global_hint": "Vorschau nur mit globalen Einträgen — wählen Sie eine Sprache, um sprachspezifische Einträge einzubeziehen.",
"test_error": "Vorschau nicht verfügbar — das Backend war nicht erreichbar.",
"backup_title": "Sichern & Wiederherstellen",
"backup_hint": "Laden Sie das Wörterbuch als JSON-Datei herunter oder importieren Sie Einträge aus einem früheren Export.",
"export": "JSON exportieren",
"import": "JSON importieren",
"import_replace_prompt": "Die {{count}} vorhandenen Einträge durch die importierte Datei ersetzen? Wählen Sie Nein, um die importierten Einträge zusätzlich hinzuzufügen.",
"import_done": "{{count}} Einträge importiert.",
"import_error": "Diese Datei konnte nicht importiert werden — erwartet wird JSON aus einem Aussprache-Export.",
"export_error": "Das Wörterbuch konnte nicht exportiert werden."
},
"audiobook": {
"title": "Hörbuch",
+185 -19
View File
@@ -15,14 +15,27 @@
"enabled": "Enabled",
"add": "Add entry",
"remove": "Remove {{term}}",
"empty": "No entries yet. Add one above to fix a mispronounced word.",
"empty": "No entries yet. Add one below to fix a mispronounced word.",
"test_label": "Test a sentence",
"test_placeholder": "Type a sentence to preview the substitution…",
"test_result": "Will be spoken as:",
"test_nochange": "No entries match — spoken as written.",
"load_error": "Couldn't load the pronunciation dictionary.",
"save_error": "Couldn't save that entry.",
"lang_label": "Language code (e.g. en, de) or leave blank for Global"
"lang_label": "Language code (e.g. en, de) or leave blank for Global",
"enable_entry": "Enable {{term}}",
"lang_placeholder": "en, de… (blank = Global)",
"test_language": "Preview language",
"test_global_hint": "Previewing Global entries only — pick a language to include language-scoped entries.",
"test_error": "Preview unavailable — couldn't reach the backend.",
"backup_title": "Backup & restore",
"backup_hint": "Download the dictionary as a JSON file, or import entries from a previous export.",
"export": "Export JSON",
"import": "Import JSON",
"import_replace_prompt": "Replace the {{count}} existing entries with the imported file? Choose No to add the imported entries alongside them.",
"import_done": "Imported {{count}} entries.",
"import_error": "Couldn't import that file — expected JSON from a pronunciation export.",
"export_error": "Couldn't export the dictionary."
},
"update": {
"available": "Update {{version}} available",
@@ -310,7 +323,7 @@
"general": "General",
"privacy": "Privacy",
"about": "About",
"ui_scale": "UI Scale",
"ui_scale": "UI scale",
"theme": "Theme",
"color_theme": "Color theme",
"font": "Font",
@@ -385,17 +398,64 @@
"llmskills_dictation_refinement_desc": "Cleans final dictation transcripts — filler words, self-corrections, punctuation. Off: the raw transcript passes through unchanged.",
"updates": "Updates",
"proxy": "Proxy",
"proxy_desc": "HTTP/SOCKS5 proxy for downloads (yt-dlp, HuggingFace). Supports http://, https://, socks5://. Restart required if changed after backend start.",
"proxy_saved": "Proxy set — yt-dlp will use it for downloads",
"proxy_desc": "HTTP/SOCKS5 proxy for downloads (video downloads, Hugging Face). Supports http://, https://, socks5://. Applies to new downloads immediately.",
"proxy_saved": "Proxy set — new downloads will use it",
"proxy_save_failed": "Failed to save proxy",
"proxy_cleared": "Proxy cleared",
"proxy_clear": "Clear",
"proxy_input_aria": "Proxy URL",
"ffmpeg_input_aria": "FFmpeg path",
"remote_backend_title": "Remote backend",
"remote_backend_desc": "Run inference on another machine; leave the URL empty for the local backend. Saving reloads the app to apply.",
"remote_backend_hint": "Start the backend on the other machine with <1>OMNIVOICE_API_KEY</1> set, reach it over your tailnet, and point this app at it.",
"remote_backend_url": "Backend URL",
"remote_backend_key": "API key",
"remote_backend_key_placeholder": "value of OMNIVOICE_API_KEY on the server",
"remote_backend_test": "Test connection",
"remote_backend_save": "Save & reload",
"remote_backend_probe_ok": "OK — {{detail}}",
"remote_backend_probe_fail": "Failed — {{detail}}",
"remote_backend_unreachable": "unreachable",
"remote_backend_invalid_url": "Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).",
"remote_backend_confirm_unverified": "This backend URL hasn't passed a connection test. Save it and reload anyway? If it's wrong, the app can't reach any backend until you change it back here.",
"remote_backend_confirm_title": "Use unverified backend?",
"ffmpeg": "FFmpeg",
"ffmpeg_found": "Found",
"ffmpeg_missing": "Not found",
"ffmpeg_current": "Current path",
"ffmpeg_desc": "Set a custom ffmpeg path if auto-detection fails.",
"ffmpeg_saved": "FFmpeg path set — restart backend to apply.",
"audio_tools": "Audio tools",
"audio_tools_desc": "The media engine (FFmpeg, FFprobe) and video downloader (yt-dlp) the app manages for you.",
"audio_tools_origin_bundled": "Bundled",
"audio_tools_origin_system": "System",
"audio_tools_origin_custom": "Custom",
"audio_tools_origin_sidecar": "App package",
"audio_tools_not_found": "Not available",
"audio_tools_version_unknown": "version unknown",
"audio_tools_use_system": "Use system copy",
"audio_tools_choose_file": "Choose file…",
"audio_tools_restore": "Restore bundled",
"audio_tools_update_bundle": "Update bundled build",
"audio_tools_bundle_updating": "Downloading bundled build… {{percent}}%",
"audio_tools_bundle_done": "Bundled media engine ready.",
"audio_tools_bundle_failed": "Bundled download failed: {{message}}",
"audio_tools_path_set": "{{tool}} now uses {{path}}",
"audio_tools_path_failed": "Couldn't set path: {{message}}",
"audio_tools_restored": "{{tool}} restored to the app-managed build.",
"audio_tools_path_input_aria": "{{tool}} binary path",
"audio_tools_manual_hint": "Prefer your package manager? Install FFmpeg yourself (macOS: brew install ffmpeg · Debian/Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg) and press Use system copy. Nothing is ever installed system-wide by the app.",
"audio_tools_ffmpeg_desc": "Converts, dubs, and mixes audio/video. The app resolves it automatically — override only if you need a specific build.",
"audio_tools_ffprobe_desc": "Reads media metadata (durations, frame rates) for Smart Fit and imports.",
"audio_tools_ytdlp": "yt-dlp (video downloader)",
"audio_tools_ytdlp_desc": "Powers video/clip imports. Site support changes faster than app releases — update it here when imports start failing.",
"audio_tools_ytdlp_update": "Update",
"audio_tools_ytdlp_updated": "yt-dlp {{version}} installed — restart the backend to apply.",
"audio_tools_ytdlp_update_failed": "yt-dlp update failed: {{message}}",
"audio_tools_ytdlp_restore": "Restore tested version",
"audio_tools_ytdlp_restored": "Tested yt-dlp restored — restart the backend to apply.",
"audio_tools_moved_note": "The FFmpeg override moved to its own panel with more control (version, origin, restore).",
"audio_tools_open": "Open Audio tools",
"diagnostics_copied": "Diagnostics copied — paste into your issue report.",
"updater_desktop": "Updater only runs in the desktop app.",
"latest_version": "You're on the latest version.",
@@ -455,6 +515,27 @@
"hf_token_load_error": "Failed to load token state",
"hf_token_save_error": "Failed to save token",
"hf_token_clear_error": "Failed to clear token",
"hf_token_checking": "Checking token sources…",
"retry": "Retry",
"llmskills_route_for": "Provider for {{skill}}",
"mcp_title": "MCP voice bindings",
"mcp_desc": "Give each MCP agent its own voice — bind the client id an agent sends to a voice profile.",
"mcp_hint": "Agents reach OmniVoice at /mcp and identify themselves with a client id (e.g. claude-code). Bind that id to a voice so the agent always speaks in that profile.",
"mcp_empty": "No bindings yet — add an agent's client id below.",
"mcp_load_failed": "Failed to load MCP bindings",
"mcp_save_failed": "Failed to save binding",
"mcp_delete_failed": "Failed to delete binding",
"mcp_delete_confirm": "Remove the voice binding for “{{clientId}}”?",
"mcp_delete_confirm_title": "Remove binding",
"mcp_remove": "Remove {{clientId}}",
"mcp_add_title": "Add binding",
"mcp_add": "Add binding",
"mcp_client_id": "Client ID",
"mcp_client_id_placeholder": "Client ID (e.g. claude-code)",
"mcp_label": "Label",
"mcp_label_placeholder": "Label (optional)",
"mcp_voice_profile": "Voice profile",
"mcp_default_voice": "Default voice",
"advanced": "Advanced",
"shortcut": "Dictation shortcut",
"credentials_desc": "API keys and tokens, set for this session.",
@@ -464,6 +545,7 @@
"group_system": "System",
"group_app": "App",
"search_placeholder": "Search settings…",
"search_no_results": "No settings match “{{query}}”",
"restart_required": "Restart required",
"applies_now": "Applies now",
"dictation": "Dictation",
@@ -474,7 +556,7 @@
"storage": "Storage",
"device": "Device & compute",
"device_desc": "Live hardware and backend readouts.",
"network_desc": "Proxy and FFmpeg paths for downloads and media processing.",
"network_desc": "Proxy for downloads and model fetches.",
"translation_desc": "How dubbing translates dialogue, and which engine does it.",
"translate_quality": "Translation quality",
"translate_quality_desc": "Fast is literal and quick; Cinematic uses the LLM for natural phrasing.",
@@ -489,6 +571,8 @@
"translation_llm_row_desc": "Choose, test, and activate a provider (OpenAI, OpenRouter, Groq, a local Ollama, …) in LLM Providers. Keys are stored encrypted; local providers stay fully offline.",
"translation_open_llm": "Open LLM Providers",
"review_mode": "Review mode",
"review_mode_on": "Pause for review",
"review_mode_off": "Run straight through",
"review_mode_desc": "Pause between pipeline stages so you can review ASR / translation output.",
"header_live_stats": "Show live system metrics in header",
"header_live_stats_desc": "Adds a live RAM / CPU / VRAM monitor to the top bar (off by default).",
@@ -500,7 +584,7 @@
"factory_reset_confirm": "Reset and reload",
"factory_reset_confirm_body": "This clears all saved UI preferences and reloads the app. Your voices, projects, and outputs on disk are not affected. Continue?",
"factory_reset_done": "Preferences cleared — reloading…",
"factory_reset_failed": "Reset failed",
"factory_reset_failed": "Reset failed: {{message}}",
"storage_usage": "Disk usage",
"storage_usage_desc": "What OmniVoice stores on this machine, and how much space is left.",
"storage_refresh": "Refresh",
@@ -538,7 +622,23 @@
"history_retention_cap_hint": "Starred takes never count against cleanup · 0 = unlimited",
"history_retention_saved": "Retention limit saved",
"history_retention_save_failed": "Could not save",
"history_retention_invalid": "Enter 0 or more"
"history_retention_invalid": "Enter 0 or more",
"perf_title": "Performance",
"perf_torch_compile": "Disable torch.compile (Windows)",
"perf_torch_compile_na": "Windows only — not needed on this platform",
"perf_torch_compile_note": "Falls back to eager mode — fixes Triton OOM on <16 GB GPUs.",
"perf_torch_compile_hint": "Workaround for <issueLink>#65</issueLink> — Windows users may hit Triton / <code>torch.compile</code> OOM during model load on GPUs with less than 16 GB VRAM. Enabling this sets <code>TORCH_COMPILE_DISABLE=1</code> on engine subprocesses, which falls back to eager mode. macOS and Linux are unaffected.",
"perf_load_failed": "Failed to load performance settings",
"perf_save_failed": "Failed to save setting",
"storage_load_failed": "Failed to load storage info",
"open_folder_failed": "Could not open folder",
"storage_clear_temp": "Clear temp files",
"storage_clear_temp_hint": "Delete OmniVoice's own files in the temp directory",
"storage_clear_temp_confirm": "Delete OmniVoice's temporary working files? Don't do this while a dub or batch job is running.",
"storage_clear_temp_failed": "Could not clear temporary files",
"storage_temp_cleared": "Temporary files cleared — {{freed}} freed",
"data_dir_at": "App data stored at",
"history_retention_load_failed": "Could not load the current retention limit"
},
"about": {
"app": "App",
@@ -572,9 +672,9 @@
"check_updates": "Check for updates",
"copy_diagnostics": "Copy diagnostics",
"github": "OmniVoice on GitHub",
"model_card": "Model card",
"commercial_license": "Commercial License",
"commercial_license": "Commercial license",
"self_check": "Run self-check",
"open_fix_category": "Open {{category}}",
"self_check_failed": "Self-check failed: {{message}}",
"self_check_ok": "OK",
"self_check_warn": "Warning",
@@ -595,6 +695,8 @@
"local_sqlite": "Local SQLite",
"translator_online": "Translator is online: {{provider}}",
"translator_offline": "Offline translator",
"translator_unknown": "Unknown",
"change_translator": "Change translator",
"no_tracking": "None — no tracking"
},
"credentials": {
@@ -631,7 +733,8 @@
},
"capture": {
"desc": "Global hotkeys only work in the desktop app. The web UI uses an in-page <1>Ctrl+Shift+Space</1> shortcut while the window has focus.",
"desc_detail": "The hotkey works system-wide while OmniVoice is running — it focuses the window and starts dictation. Avoid combos already claimed by the OS (on macOS, <1>⌘+Space</1> is Spotlight and <2>⌘+⇧+Space</2> cycles input sources). If registration fails, pick a different combo.",
"desc_detail": "The hotkey works system-wide while OmniVoice is running — it focuses the window and starts dictation. Avoid combos already claimed by the OS (on macOS, <1>⌘+Space</1> is Spotlight and <2>⌘+⇧+Space</2> cycles input sources). If registration fails, pick a different combo. A shortcut needs at least one modifier (Ctrl / Cmd / Alt / Shift) combined with a regular key.",
"needs_modifier": "Add a modifier — Ctrl / Cmd / Alt / Shift + key (Esc to cancel)",
"active_shortcut": "Active shortcut",
"new_shortcut": "New shortcut",
"press_key": "Press a key combo…",
@@ -1285,7 +1388,15 @@
"system_check": "System check",
"install_models": "Install models",
"pick_engines": "Pick engines",
"system_check_desc": "Probe RAM, disk, GPU, ffmpeg, and network. Blockers are flagged upfront so you know before downloading.",
"system_check_desc": "Probe RAM, disk, GPU, and network. Blockers are flagged upfront so you know before downloading.",
"media_engine_preparing": "Preparing media engine…",
"media_engine_failed_title": "Media engine download failed",
"media_engine_failed_desc": "The app couldn't fetch its bundled audio/video engine (FFmpeg). Retry, or point it at a copy already on this computer.",
"media_engine_retry": "Retry",
"media_engine_use_system": "Use a system copy",
"media_engine_detect_failed": "No system copy found — retry the download or choose the file manually.",
"media_engine_choose_file": "Choose file…",
"media_engine_ready": "Media engine configured.",
"install_models_desc": "Download ~5 GB of weights — TTS + Whisper. Required models first, optional ones later.",
"pick_engines_desc": "Choose TTS / ASR / LLM backends. Defaults work out of the box — customize anytime in Settings.",
"hero_desc": "Dubbing, voice cloning, and voice design — all running locally on your machine.",
@@ -1562,8 +1673,6 @@
"taxonomyTokens": "taxonomy tokens"
},
"engines": {
"review_on": "Review",
"review_off": "Rapid-fire",
"banners_on": "Stage banners on",
"banners_off": "Stage banners off",
"backend": "Backend",
@@ -1575,6 +1684,10 @@
"refresh": "Refresh",
"matrixTitle": "Engine Compatibility Matrix",
"familyMatrixTitle": "{{family}} Engines",
"colEngine": "Engine",
"colGpuCompat": "GPU compat",
"colIsolation": "Isolation",
"colActions": "Actions",
"loadFailed": "Failed to load engines: {{message}}",
"couldNotLoad": "Could not load engines: {{message}}",
"retry": "Retry",
@@ -1616,7 +1729,17 @@
"routingCaveatTitle": "GPU selected, but: {{reason}}",
"selectCpuFallback": "{{engine}}: running on CPU — {{reason}}",
"curatedModelLabel": "Model",
"curatedModelAria": "Model for {{engine}}"
"curatedModelAria": "Model for {{engine}}",
"cloneCapable": "Voice cloning",
"cloneCapableTitle": "Can clone a voice from a short reference clip",
"inMemory": "In memory",
"inMemoryTitle": "Loaded in memory right now — unloading frees RAM/VRAM and it reloads on next use",
"unload": "Unload",
"unloading": "Unloading…",
"unloadFailed": "Could not unload: {{message}}",
"familyDesc_tts": "Turns your script into speech. The engine marked active is the one Studio, Dubbing and Batch use.",
"familyDesc_asr": "Turns audio into text — transcription for dubbing, captions and dictation.",
"familyDesc_llm": "Optional text helper for translation and rewrites. \"Off\" simply skips those steps."
},
"errors": {
"title": "This tab hit a snag.",
@@ -1671,7 +1794,8 @@
"no": "No",
"more_info": "More info",
"learn_more": "Learn more",
"saving": "Saving…"
"saving": "Saving…",
"unknown": "unknown"
},
"keyboard": {
"title": "Keyboard shortcuts",
@@ -1879,7 +2003,10 @@
"log_copied": "Copied {{source}} log",
"copy_failed": "Copy failed: {{message}}",
"report_copied": "Diagnostic report copied — paste it into a GitHub issue.",
"report_failed": "Report failed: {{message}}"
"report_failed": "Report failed: {{message}}",
"source": "Log source",
"frontend_buffer": "in-memory (last 500)",
"copy_failed_short": "Could not copy the log"
},
"trimmer": {
"title": "Trim reference audio",
@@ -2024,6 +2151,15 @@
"search_placeholder": "Search models…",
"search_label": "Search models",
"no_matches": "No models match your filters.",
"clear_filters": "Clear filters",
"in_memory": "In memory",
"in_memory_title": "Loaded in memory right now — unloading frees RAM/VRAM and it reloads on next use",
"unload_btn": "Unload",
"unload_aria": "Unload {{repoId}} from memory",
"unloaded_toast": "Unloaded — memory freed",
"unload_failed": "Could not unload: {{message}}",
"reco_disk_free": "{{free}} GB free on the model disk",
"reco_low_disk": "Not enough space for the full bundle: it needs ~{{need}} GB but only {{free}} GB is free. Install just the required models, or free up space first.",
"sort_by": "Sort by {{column}}",
"column_model": "Model",
"column_role": "Role",
@@ -2047,6 +2183,10 @@
"mirror_restart_note": "Model Store downloads use the new mirror immediately. Only model loads (transformers) pick it up after a restart.",
"mirror_load_error": "Failed to load mirror setting",
"mirror_save_error": "Failed to save",
"mirror_custom_url": "Custom mirror URL",
"mirror_custom_url_note": "Sets the HF_ENDPOINT environment variable for Hugging Face downloads.",
"mirror_saved": "Mirror setting saved",
"mirror_retry": "Retry",
"asrOpenAICompatTitle": "OpenAI-compatible ASR (remote server)",
"asrOpenAICompatDescription": "Point transcription at Qwen3-ASR, a self-hosted FunASR/SenseVoice server, or OpenAI's own API.",
"asrOpenAICompatBaseUrlTitle": "Server URL",
@@ -2057,7 +2197,8 @@
"asrOpenAICompatApiKeyOptional": "optional",
"asrOpenAICompatKeyConfigured": "A key is saved. Leave blank to keep it, or type a new one to replace it.",
"asrOpenAICompatLoadError": "Failed to load ASR server setting",
"asrOpenAICompatSaveError": "Failed to save"
"asrOpenAICompatSaveError": "Failed to save",
"asrOpenAICompatSaved": "Saved"
},
"enterprise_faq": {
"q_internal_tools": "Do I need a license for internal tools?",
@@ -2251,6 +2392,7 @@
"copy_url": "Copy spec URL",
"copy_url_aria": "Copy the /openapi.json URL",
"copied": "Spec URL copied",
"copy_failed": "Copy failed — select and copy the URL above manually.",
"open_raw": "Open raw spec",
"open_raw_aria": "Open the raw OpenAPI JSON in your browser"
},
@@ -2391,5 +2533,29 @@
"cleaned_loaded": "Recording cleaned & loaded!",
"loaded_raw": "Recording loaded (raw — denoising unavailable)",
"too_short": "Recording too short"
},
"dictation": {
"title": "Dictation refinement",
"needs_llm": "Needs a local LLM endpoint — until then, raw transcripts paste unchanged.",
"no_llm_configured": "no LLM configured",
"open_llm_providers": "Open LLM Providers",
"load_error": "Failed to load refinement settings",
"save_error": "Failed to save setting",
"retry": "Retry",
"flag_auto": "Refine dictation with the local LLM",
"flag_auto_hint": "Master switch — applied to final transcripts only, never live partials. The raw transcript is always kept in History.",
"flag_smart_cleanup": "Remove filler words & add punctuation",
"flag_smart_cleanup_hint": "\"so um like the meeting is at 3pm you know\" → \"So the meeting is at 3pm.\"",
"flag_self_correction": "Apply spoken self-corrections",
"flag_self_correction_hint": "\"at seven no actually six am\" → \"at six am\"",
"flag_preserve_technical": "Preserve technical terms & spoken symbols",
"flag_preserve_technical_hint": "\"index dot tsx\" → \"index.tsx\"; identifiers stay verbatim",
"refine_timeout_note": "The last dictation refinement timed out — the LLM endpoint is slow or unreachable. Dictation still works (the raw transcript is inserted). Test the connection in LLM Providers.",
"refine_failed_note": "The last dictation refinement failed — the configured LLM endpoint rejected the request. Dictation still works (the raw transcript is inserted). Test the connection in LLM Providers.",
"aec_title": "Dictate while audio plays",
"aec_description": "Cancel OmniVoice's own playback out of the microphone.",
"aec_row_title": "Enable echo cancellation for dictation",
"aec_experimental": "Experimental",
"aec_hint": "Cancels OmniVoice's own playback out of the microphone so you can dictate while a preview, dub, or video is playing — without the transcript picking up what the app is saying. Adds a small amount of audio processing; leave it off if you never dictate over playback."
}
}
}
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "Configuración",
"ui_scale": "Escala de interfaz de usuario",
"proxy": "apoderado",
"proxy_desc": "Proxy HTTP/SOCKS5 para descargas (yt-dlp, HuggingFace). Admite http://, https://, calcetines5://. Se requiere reinicio si se cambia después del inicio del backend.",
"proxy_saved": "Conjunto de proxy: yt-dlp lo usará para descargas",
"proxy_desc": "Proxy HTTP/SOCKS5 para descargas (descargas de vídeo, Hugging Face). Admite http://, https://, socks5://. Se aplica de inmediato a las nuevas descargas.",
"proxy_saved": "Proxy configurado — las nuevas descargas lo usarán",
"proxy_save_failed": "No se pudo guardar el proxy",
"proxy_cleared": "Proxy borrado",
"proxy_clear": "Borrar",
@@ -153,6 +153,8 @@
"translation_providers": "Proveedores de traducción",
"translation_providers_desc": "Claves API para traductores en línea, configuradas para esta sesión.",
"review_mode": "Modo de revisión",
"review_mode_on": "Revisión",
"review_mode_off": "Fuego rápido",
"review_mode_desc": "Haga una pausa entre las etapas del proceso para que pueda revisar el resultado de ASR/traducción.",
"header_live_stats": "Mostrar métricas del sistema en vivo en el encabezado",
"header_live_stats_desc": "Agrega un monitor de RAM/CPU/VRAM en vivo a la barra superior (desactivado de forma predeterminada).",
@@ -465,7 +467,6 @@
"check_updates": "Buscar actualizaciones",
"copy_diagnostics": "Copiar diagnóstico",
"github": "OmniVoice en GitHub",
"model_card": "tarjeta modelo",
"commercial_license": "Licencia Comercial",
"update_channel": "Canal de actualización",
"channel_stable": "Estable",
@@ -527,8 +528,6 @@
"group_microsoft": "Punto final personalizado de Microsoft Translator"
},
"engines": {
"review_on": "Revisión",
"review_off": "Fuego rápido",
"banners_on": "Pancartas del escenario encendidas",
"banners_off": "pancartas del escenario apagadas",
"backend": "backend",
@@ -1997,14 +1996,27 @@
"enabled": "Habilitado",
"add": "Agregar entrada",
"remove": "Eliminar {{term}}",
"empty": "Aún no hay entradas. Agregue uno arriba para corregir una palabra mal pronunciada.",
"empty": "Aún no hay entradas. Agregue una abajo para corregir una palabra mal pronunciada.",
"test_label": "Probar una oración",
"test_placeholder": "Escriba una oración para obtener una vista previa de la sustitución...",
"test_result": "Se hablará como:",
"test_nochange": "Ninguna entrada coincide: hablada tal como está escrita.",
"load_error": "No se pudo cargar el diccionario de pronunciación.",
"save_error": "No se pudo guardar esa entrada.",
"lang_label": "Código de idioma (por ejemplo, en, de) o déjelo en blanco para Global"
"lang_label": "Código de idioma (por ejemplo, en, de) o déjelo en blanco para Global",
"enable_entry": "Activar {{term}}",
"lang_placeholder": "en, de… (vacío = Mundial)",
"test_language": "Idioma de la vista previa",
"test_global_hint": "Vista previa solo con entradas globales — elija un idioma para incluir las entradas por idioma.",
"test_error": "Vista previa no disponible — no se pudo contactar el backend.",
"backup_title": "Copia de seguridad y restauración",
"backup_hint": "Descargue el diccionario como archivo JSON o importe entradas de una exportación anterior.",
"export": "Exportar JSON",
"import": "Importar JSON",
"import_replace_prompt": "¿Reemplazar las {{count}} entradas existentes con el archivo importado? Elija No para añadir las entradas importadas junto a las existentes.",
"import_done": "Se importaron {{count}} entradas.",
"import_error": "No se pudo importar ese archivo — se esperaba JSON de una exportación de pronunciación.",
"export_error": "No se pudo exportar el diccionario."
},
"audiobook": {
"title": "Audiolibro",
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "Paramètres",
"ui_scale": "Échelle de l'interface utilisateur",
"proxy": "Procuration",
"proxy_desc": "Proxy HTTP/SOCKS5 pour les téléchargements (yt-dlp, HuggingFace). Prend en charge http://, https://, chaussettes5://. Redémarrage requis en cas de modification après le démarrage du backend.",
"proxy_saved": "Ensemble de proxy  yt-dlp l'utilisera pour les téléchargements",
"proxy_desc": "Proxy HTTP/SOCKS5 pour les téléchargements (téléchargements vidéo, Hugging Face). Prend en charge http://, https://, socks5://. S'applique immédiatement aux nouveaux téléchargements.",
"proxy_saved": "Proxy défini — les nouveaux téléchargements l'utiliseront",
"proxy_save_failed": "Échec de l'enregistrement du proxy",
"proxy_cleared": "Proxy effacé",
"proxy_clear": "Effacer",
@@ -153,6 +153,8 @@
"translation_providers": "Prestataires de traduction",
"translation_providers_desc": "Clés API pour les traducteurs en ligne, définies pour cette session.",
"review_mode": "Mode révision",
"review_mode_on": "Examen",
"review_mode_off": "Tir rapide",
"review_mode_desc": "Faites une pause entre les étapes du pipeline afin de pouvoir examiner le résultat ASR/traduction.",
"header_live_stats": "Afficher les métriques du système en direct dans l'en-tête",
"header_live_stats_desc": "Ajoute un moniteur RAM / CPU / VRAM en direct à la barre supérieure (désactivé par défaut).",
@@ -465,7 +467,6 @@
"check_updates": "Vérifier les mises à jour",
"copy_diagnostics": "Copier les diagnostics",
"github": "OmniVoice sur GitHub",
"model_card": "Carte modèle",
"commercial_license": "Licence commerciale",
"update_channel": "Canal de mise à jour",
"channel_stable": "Stable",
@@ -527,8 +528,6 @@
"group_microsoft": "Point de terminaison personnalisé du traducteur Microsoft"
},
"engines": {
"review_on": "Examen",
"review_off": "Tir rapide",
"banners_on": "Bannières de scène sur",
"banners_off": "Les bannières de la scène sont retirées",
"backend": "Back-end",
@@ -1997,14 +1996,27 @@
"enabled": "Activé",
"add": "Ajouter une entrée",
"remove": "Supprimer {{term}}",
"empty": "Aucune entrée pour l'instant. Ajoutez-en un ci-dessus pour corriger un mot mal prononcé.",
"empty": "Aucune entrée pour l'instant. Ajoutez-en une ci-dessous pour corriger un mot mal prononcé.",
"test_label": "Tester une phrase",
"test_placeholder": "Tapez une phrase pour prévisualiser la substitution…",
"test_result": "Sera prononcé comme :",
"test_nochange": "Aucune entrée ne correspond prononcée comme écrite.",
"load_error": "Impossible de charger le dictionnaire de prononciation.",
"save_error": "Impossible d'enregistrer cette entrée.",
"lang_label": "Code de langue (par exemple en, de) ou laissez vide pour Global"
"lang_label": "Code de langue (par exemple en, de) ou laissez vide pour Global",
"enable_entry": "Activer {{term}}",
"lang_placeholder": "en, de… (vide = Mondial)",
"test_language": "Langue de l'aperçu",
"test_global_hint": "Aperçu avec les entrées globales uniquement — choisissez une langue pour inclure les entrées propres à une langue.",
"test_error": "Aperçu indisponible — le backend n'a pas répondu.",
"backup_title": "Sauvegarde et restauration",
"backup_hint": "Téléchargez le dictionnaire au format JSON ou importez des entrées d'un export précédent.",
"export": "Exporter JSON",
"import": "Importer JSON",
"import_replace_prompt": "Remplacer les {{count}} entrées existantes par le fichier importé ? Choisissez Non pour ajouter les entrées importées aux entrées existantes.",
"import_done": "{{count}} entrées importées.",
"import_error": "Impossible d'importer ce fichier — JSON d'un export de prononciation attendu.",
"export_error": "Impossible d'exporter le dictionnaire."
},
"audiobook": {
"title": "Livre audio",
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "सेटिंग्स",
"ui_scale": "यूआई स्केल",
"proxy": "प्रॉक्सी",
"proxy_desc": "डाउनलोड के लिए HTTP/SOCKS5 प्रॉक्सी (yt-dlp, HuggingFace)। http://, https://, socks5:// को सपोर्ट करता है। यदि बैकएंड प्रारंभ के बाद बदला गया है तो पुनः प्रारंभ करना आवश्यक है।",
"proxy_saved": "प्रॉक्सी सेट - yt-dlp इसे डाउनलोड के लिए उपयोग करेगा",
"proxy_desc": "डाउनलोड के लिए HTTP/SOCKS5 प्रॉक्सी (वीडियो डाउनलोड, Hugging Face)। http://, https://, socks5:// को सपोर्ट करता है। नए डाउनलोड पर तुरंत लागू होता है।",
"proxy_saved": "प्रॉक्सी सेट — नए डाउनलोड इसका उपयोग करेंगे",
"proxy_save_failed": "प्रॉक्सी सहेजने में विफल",
"proxy_cleared": "प्रॉक्सी साफ़ कर दी गई",
"proxy_clear": "स्पष्ट",
@@ -153,6 +153,8 @@
"translation_providers": "अनुवाद प्रदाता",
"translation_providers_desc": "इस सत्र के लिए ऑनलाइन अनुवादकों के लिए एपीआई कुंजियाँ निर्धारित की गई हैं।",
"review_mode": "समीक्षा मोड",
"review_mode_on": "समीक्षा",
"review_mode_off": "तेजी से आग",
"review_mode_desc": "पाइपलाइन चरणों के बीच रुकें ताकि आप एएसआर/अनुवाद आउटपुट की समीक्षा कर सकें।",
"header_live_stats": "हेडर में लाइव सिस्टम मेट्रिक्स दिखाएं",
"header_live_stats_desc": "शीर्ष बार में एक लाइव रैम/सीपीयू/वीआरएएम मॉनिटर जोड़ता है (डिफ़ॉल्ट रूप से बंद)।",
@@ -465,7 +467,6 @@
"check_updates": "अपडेट के लिए जांचें",
"copy_diagnostics": "डायग्नोस्टिक्स की प्रतिलिपि बनाएँ",
"github": "GitHub पर ओमनीवॉइस",
"model_card": "मॉडल कार्ड",
"commercial_license": "वाणिज्यिक लाइसेंस",
"update_channel": "अपडेट चैनल",
"channel_stable": "स्थिर",
@@ -527,8 +528,6 @@
"group_microsoft": "माइक्रोसॉफ्ट ट्रांसलेटर कस्टम एंडपॉइंट"
},
"engines": {
"review_on": "समीक्षा",
"review_off": "तेजी से आग",
"banners_on": "मंच पर बैनर",
"banners_off": "मंच के बैनर उतारे गए",
"backend": "बैकएंड",
@@ -1997,14 +1996,27 @@
"enabled": "सक्षम",
"add": "प्रविष्टि जोड़ें",
"remove": "{{term}} हटाएं",
"empty": "अभी तक कोई प्रविष्टि नहीं. गलत उच्चारण वाले शब्द को ठीक करने के लिए ऊपर एक जोड़ें।",
"empty": "अभी तक कोई प्रविष्टि नहीं. गलत उच्चारण वाले शब्द को ठीक करने के लिए नीचे एक जोड़ें।",
"test_label": "एक वाक्य का परीक्षण करें",
"test_placeholder": "प्रतिस्थापन का पूर्वावलोकन करने के लिए एक वाक्य टाइप करें...",
"test_result": "इस प्रकार बोला जाएगा:",
"test_nochange": "कोई प्रविष्टियाँ मेल नहीं खातीं - जैसा लिखा है वैसा ही बोला जाता है।",
"load_error": "उच्चारण शब्दकोश लोड नहीं किया जा सका.",
"save_error": "उस प्रविष्टि को सहेजा नहीं जा सका.",
"lang_label": "भाषा कोड (उदा. एन, डी) या ग्लोबल के लिए खाली छोड़ दें"
"lang_label": "भाषा कोड (उदा. एन, डी) या ग्लोबल के लिए खाली छोड़ दें",
"enable_entry": "{{term}} सक्षम करें",
"lang_placeholder": "en, de… (खाली = वैश्विक)",
"test_language": "पूर्वावलोकन भाषा",
"test_global_hint": "केवल वैश्विक प्रविष्टियों का पूर्वावलोकन — भाषा-विशिष्ट प्रविष्टियाँ शामिल करने के लिए एक भाषा चुनें।",
"test_error": "पूर्वावलोकन उपलब्ध नहीं — बैकएंड से संपर्क नहीं हो सका।",
"backup_title": "बैकअप और पुनर्स्थापना",
"backup_hint": "शब्दकोश को JSON फ़ाइल के रूप में डाउनलोड करें, या पिछले निर्यात से प्रविष्टियाँ आयात करें।",
"export": "JSON निर्यात करें",
"import": "JSON आयात करें",
"import_replace_prompt": "मौजूदा {{count}} प्रविष्टियों को आयातित फ़ाइल से बदलें? आयातित प्रविष्टियों को मौजूदा के साथ जोड़ने के लिए 'नहीं' चुनें।",
"import_done": "{{count}} प्रविष्टियाँ आयात की गईं।",
"import_error": "वह फ़ाइल आयात नहीं हो सकी — उच्चारण निर्यात की JSON अपेक्षित थी।",
"export_error": "शब्दकोश निर्यात नहीं हो सका।"
},
"audiobook": {
"title": "ऑडियोबुक",
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "Pengaturan",
"ui_scale": "Skala UI",
"proxy": "Proksi",
"proxy_desc": "Proksi HTTP/SOCKS5 untuk unduhan (yt-dlp, HuggingFace). Mendukung http://, https://, kaus kaki5://. Mulai ulang diperlukan jika diubah setelah backend dimulai.",
"proxy_saved": "Kumpulan proxy — yt-dlp akan menggunakannya untuk mengunduh",
"proxy_desc": "Proksi HTTP/SOCKS5 untuk unduhan (unduhan video, Hugging Face). Mendukung http://, https://, socks5://. Langsung berlaku untuk unduhan baru.",
"proxy_saved": "Proksi disetel — unduhan baru akan menggunakannya",
"proxy_save_failed": "Gagal menyimpan proksi",
"proxy_cleared": "Proksi dihapus",
"proxy_clear": "Jelas",
@@ -153,6 +153,8 @@
"translation_providers": "Penyedia terjemahan",
"translation_providers_desc": "Kunci API untuk penerjemah online, disetel untuk sesi ini.",
"review_mode": "Modus tinjauan",
"review_mode_on": "Ulasan",
"review_mode_off": "Tembakan cepat",
"review_mode_desc": "Jeda di antara tahapan alur sehingga Anda dapat meninjau ASR/output terjemahan.",
"header_live_stats": "Tampilkan metrik sistem langsung di header",
"header_live_stats_desc": "Menambahkan monitor RAM/CPU/VRAM langsung ke bilah atas (dinonaktifkan secara default).",
@@ -465,7 +467,6 @@
"check_updates": "Periksa pembaruan",
"copy_diagnostics": "Salin diagnostik",
"github": "OmniVoice di GitHub",
"model_card": "Kartu model",
"commercial_license": "Lisensi Komersial",
"update_channel": "Saluran pembaruan",
"channel_stable": "Stabil",
@@ -527,8 +528,6 @@
"group_microsoft": "Titik Akhir Kustom Penerjemah Microsoft"
},
"engines": {
"review_on": "Ulasan",
"review_off": "Tembakan cepat",
"banners_on": "Spanduk panggung menyala",
"banners_off": "Spanduk panggung dilepas",
"backend": "Bagian belakang",
@@ -1997,14 +1996,27 @@
"enabled": "Diaktifkan",
"add": "Tambahkan entri",
"remove": "Hapus {{term}}",
"empty": "Belum ada entri. Tambahkan satu di atas untuk memperbaiki kata yang salah diucapkan.",
"empty": "Belum ada entri. Tambahkan satu di bawah untuk memperbaiki kata yang salah diucapkan.",
"test_label": "Uji sebuah kalimat",
"test_placeholder": "Ketikkan kalimat untuk melihat pratinjau substitusi…",
"test_result": "Akan diucapkan sebagai:",
"test_nochange": "Tidak ada entri yang cocok — diucapkan seperti tertulis.",
"load_error": "Tidak dapat memuat kamus pengucapan.",
"save_error": "Tidak dapat menyimpan entri itu.",
"lang_label": "Kode bahasa (misalnya en, de) atau biarkan kosong untuk Global"
"lang_label": "Kode bahasa (misalnya en, de) atau biarkan kosong untuk Global",
"enable_entry": "Aktifkan {{term}}",
"lang_placeholder": "en, de… (kosong = Global)",
"test_language": "Bahasa pratinjau",
"test_global_hint": "Pratinjau hanya dengan entri Global — pilih bahasa untuk menyertakan entri khusus bahasa.",
"test_error": "Pratinjau tidak tersedia — backend tidak dapat dihubungi.",
"backup_title": "Cadangkan & pulihkan",
"backup_hint": "Unduh kamus sebagai file JSON, atau impor entri dari ekspor sebelumnya.",
"export": "Ekspor JSON",
"import": "Impor JSON",
"import_replace_prompt": "Ganti {{count}} entri yang ada dengan file yang diimpor? Pilih Tidak untuk menambahkan entri impor di samping yang ada.",
"import_done": "{{count}} entri diimpor.",
"import_error": "File tidak dapat diimpor — diharapkan JSON dari ekspor pelafalan.",
"export_error": "Kamus tidak dapat diekspor."
},
"audiobook": {
"title": "Buku Audio",
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "Impostazioni",
"ui_scale": "Scala dell'interfaccia utente",
"proxy": "Procura",
"proxy_desc": "Proxy HTTP/SOCKS5 per i download (yt-dlp, HuggingFace). Supporta http://, https://, calzini5://. È necessario riavviare se modificato dopo l'avvio del backend.",
"proxy_saved": "Set proxy: yt-dlp lo utilizzerà per i download",
"proxy_desc": "Proxy HTTP/SOCKS5 per i download (download video, Hugging Face). Supporta http://, https://, socks5://. Si applica subito ai nuovi download.",
"proxy_saved": "Proxy impostato — i nuovi download lo useranno",
"proxy_save_failed": "Impossibile salvare il proxy",
"proxy_cleared": "Proxy cancellato",
"proxy_clear": "Chiaro",
@@ -153,6 +153,8 @@
"translation_providers": "Fornitori di traduzioni",
"translation_providers_desc": "Chiavi API per traduttori online, impostate per questa sessione.",
"review_mode": "Modalità revisione",
"review_mode_on": "Recensione",
"review_mode_off": "Fuoco rapido",
"review_mode_desc": "Fai una pausa tra le fasi della pipeline in modo da poter rivedere l'output ASR/traduzione.",
"header_live_stats": "Mostra le metriche del sistema in tempo reale nell'intestazione",
"header_live_stats_desc": "Aggiunge un monitor RAM/CPU/VRAM live alla barra superiore (disattivato per impostazione predefinita).",
@@ -465,7 +467,6 @@
"check_updates": "Controlla gli aggiornamenti",
"copy_diagnostics": "Copia la diagnostica",
"github": "OmniVoice su GitHub",
"model_card": "Scheda modello",
"commercial_license": "Licenza commerciale",
"update_channel": "Canale di aggiornamento",
"channel_stable": "Stabile",
@@ -527,8 +528,6 @@
"group_microsoft": "Endpoint personalizzato di Microsoft Translator"
},
"engines": {
"review_on": "Recensione",
"review_off": "Fuoco rapido",
"banners_on": "Striscioni sul palco accesi",
"banners_off": "Gli striscioni sul palco sono spenti",
"backend": "Backend",
@@ -1997,14 +1996,27 @@
"enabled": "Abilitato",
"add": "Aggiungi voce",
"remove": "Rimuovi {{term}}",
"empty": "Nessuna voce ancora. Aggiungine uno sopra per correggere una parola pronunciata male.",
"empty": "Nessuna voce ancora. Aggiungine una qui sotto per correggere una parola pronunciata male.",
"test_label": "Metti alla prova una frase",
"test_placeholder": "Digita una frase per visualizzare in anteprima la sostituzione...",
"test_result": "Verrà parlato come:",
"test_nochange": "Nessuna voce corrisponde: pronunciata come scritta.",
"load_error": "Impossibile caricare il dizionario della pronuncia.",
"save_error": "Impossibile salvare la voce.",
"lang_label": "Codice della lingua (ad esempio en, de) o lasciare vuoto per Global"
"lang_label": "Codice della lingua (ad esempio en, de) o lasciare vuoto per Global",
"enable_entry": "Attiva {{term}}",
"lang_placeholder": "en, de… (vuoto = Globale)",
"test_language": "Lingua dell'anteprima",
"test_global_hint": "Anteprima solo con le voci globali — scegli una lingua per includere le voci specifiche per lingua.",
"test_error": "Anteprima non disponibile — impossibile raggiungere il backend.",
"backup_title": "Backup e ripristino",
"backup_hint": "Scarica il dizionario come file JSON oppure importa voci da un'esportazione precedente.",
"export": "Esporta JSON",
"import": "Importa JSON",
"import_replace_prompt": "Sostituire le {{count}} voci esistenti con il file importato? Scegli No per aggiungere le voci importate a quelle esistenti.",
"import_done": "{{count}} voci importate.",
"import_error": "Impossibile importare il file — è atteso il JSON di un'esportazione della pronuncia.",
"export_error": "Impossibile esportare il dizionario."
},
"audiobook": {
"title": "Audiolibro",
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "設定",
"ui_scale": "UIスケール",
"proxy": "プロキシ",
"proxy_desc": "ダウンロード用の HTTP/SOCKS5 プロキシ (yt-dlp、HuggingFace)。 http://、https://、socks5:// をサポートします。バックエンドの起動後に変更した場合は再起動が必要です。",
"proxy_saved": "プロキシ セット — yt-dlp はダウンロードにそれを使用します",
"proxy_desc": "ダウンロード用の HTTP/SOCKS5 プロキシ(動画ダウンロード、Hugging Face)。http://、https://、socks5:// をサポートします。新しいダウンロードにすぐに適用されます。",
"proxy_saved": "プロキシを設定しました — 新しいダウンロードで使用されます",
"proxy_save_failed": "プロキシの保存に失敗しました",
"proxy_cleared": "プロキシがクリアされました",
"proxy_clear": "クリア",
@@ -153,6 +153,8 @@
"translation_providers": "翻訳プロバイダー",
"translation_providers_desc": "このセッション用に設定されたオンライン翻訳者の API キー。",
"review_mode": "レビューモード",
"review_mode_on": "レビュー",
"review_mode_off": "連射",
"review_mode_desc": "パイプライン ステージ間で一時停止して、ASR/翻訳出力を確認できるようにします。",
"header_live_stats": "ライブシステムメトリクスをヘッダーに表示",
"header_live_stats_desc": "ライブ RAM / CPU / VRAM モニターをトップバーに追加します (デフォルトではオフ)。",
@@ -465,7 +467,6 @@
"check_updates": "アップデートをチェックする",
"copy_diagnostics": "コピー診断",
"github": "GitHub 上のオムニボイス",
"model_card": "モデルカード",
"commercial_license": "商用ライセンス",
"update_channel": "アップデートチャンネル",
"channel_stable": "安定版",
@@ -527,8 +528,6 @@
"group_microsoft": "Microsoft 翻訳者のカスタム エンドポイント"
},
"engines": {
"review_on": "レビュー",
"review_off": "連射",
"banners_on": "ステージバナーが貼られています",
"banners_off": "ステージバナーが消えた",
"backend": "バックエンド",
@@ -1997,14 +1996,27 @@
"enabled": "有効",
"add": "エントリを追加",
"remove": "{{term}} を削除",
"empty": "まだエントリーはありません。発音が間違っている単語を修正するには、に 1 つ追加します。",
"empty": "まだエントリーはありません。発音が間違っている単語を修正するには、に 1 つ追加します。",
"test_label": "文をテストする",
"test_placeholder": "文を入力して置換をプレビューします…",
"test_result": "次のように話されます。",
"test_nochange": "一致するエントリはありません - 書かれたとおりに話されています。",
"load_error": "発音辞書を読み込めませんでした。",
"save_error": "そのエントリを保存できませんでした。",
"lang_label": "言語コード (en、de など)、またはグローバルの場合は空白のままにします"
"lang_label": "言語コード (en、de など)、またはグローバルの場合は空白のままにします",
"enable_entry": "{{term}} を有効にする",
"lang_placeholder": "en, de…(空欄 = グローバル)",
"test_language": "プレビューの言語",
"test_global_hint": "グローバルなエントリーのみをプレビューしています — 言語別のエントリーを含めるには言語を選択してください。",
"test_error": "プレビューを利用できません — バックエンドに接続できませんでした。",
"backup_title": "バックアップと復元",
"backup_hint": "辞書を JSON ファイルとしてダウンロードするか、以前のエクスポートからエントリーをインポートします。",
"export": "JSON をエクスポート",
"import": "JSON をインポート",
"import_replace_prompt": "既存の {{count}} 件のエントリーをインポートしたファイルで置き換えますか?「いいえ」を選ぶと、既存のエントリーに追加されます。",
"import_done": "{{count}} 件のエントリーをインポートしました。",
"import_error": "そのファイルをインポートできませんでした — 発音エクスポートの JSON が必要です。",
"export_error": "辞書をエクスポートできませんでした。"
},
"audiobook": {
"title": "オーディオブック",
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "설정",
"ui_scale": "UI 규모",
"proxy": "프록시",
"proxy_desc": "다운로드용 HTTP/SOCKS5 프록시(yt-dlp, HuggingFace) http://, https://, 양말5://를 지원합니다. 백엔드 시작 후 변경된 경우 다시 시작해야 합니다.",
"proxy_saved": "프록시 세트 - yt-dlp가 다운로드에 이를 사용니다.",
"proxy_desc": "다운로드용 HTTP/SOCKS5 프록시(동영상 다운로드, Hugging Face). http://, https://, socks5://를 지원합니다. 새 다운로드에 즉시 적용됩니다.",
"proxy_saved": "프록시 설정됨 — 새 다운로드에 사용니다",
"proxy_save_failed": "프록시를 저장하지 못했습니다.",
"proxy_cleared": "프록시가 삭제되었습니다.",
"proxy_clear": "지우기",
@@ -153,6 +153,8 @@
"translation_providers": "번역 제공업체",
"translation_providers_desc": "이 세션에 대해 설정된 온라인 번역기용 API 키입니다.",
"review_mode": "검토 모드",
"review_mode_on": "검토",
"review_mode_off": "속사",
"review_mode_desc": "ASR/번역 출력을 검토할 수 있도록 파이프라인 단계 사이에 일시 중지합니다.",
"header_live_stats": "헤더에 실시간 시스템 측정항목 표시",
"header_live_stats_desc": "상단 표시줄에 라이브 RAM/CPU/VRAM 모니터를 추가합니다(기본적으로 꺼져 있음).",
@@ -465,7 +467,6 @@
"check_updates": "업데이트 확인",
"copy_diagnostics": "진단 복사",
"github": "GitHub의 OmniVoice",
"model_card": "모델 카드",
"commercial_license": "상업용 라이센스",
"update_channel": "업데이트 채널",
"channel_stable": "안정 버전",
@@ -527,8 +528,6 @@
"group_microsoft": "Microsoft 번역기 사용자 지정 끝점"
},
"engines": {
"review_on": "검토",
"review_off": "속사",
"banners_on": "무대 배너 켜짐",
"banners_off": "무대 배너 꺼짐",
"backend": "백엔드",
@@ -1997,14 +1996,27 @@
"enabled": "활성화됨",
"add": "항목 추가",
"remove": "Remove {{term}}",
"empty": "아직 항목이 없습니다. Add one above to fix a mispronounced word.",
"empty": "아직 항목이 없습니다. 잘못 발음되는 단어를 고치려면 아래에 하나를 추가하세요.",
"test_label": "Test a sentence",
"test_placeholder": "Type a sentence to preview the substitution…",
"test_result": "Will be spoken as:",
"test_nochange": "일치하는 항목이 없습니다. 쓰여진 대로 말합니다.",
"load_error": "Couldn't load the pronunciation dictionary.",
"save_error": "해당 항목을 저장할 수 없습니다.",
"lang_label": "언어 코드(예: en, de) 또는 글로벌의 경우 비워 두세요."
"lang_label": "언어 코드(예: en, de) 또는 글로벌의 경우 비워 두세요.",
"enable_entry": "{{term}} 활성화",
"lang_placeholder": "en, de… (비워 두면 글로벌)",
"test_language": "미리보기 언어",
"test_global_hint": "글로벌 항목만 미리보기 중 — 언어별 항목을 포함하려면 언어를 선택하세요.",
"test_error": "미리보기를 사용할 수 없습니다 — 백엔드에 연결할 수 없습니다.",
"backup_title": "백업 및 복원",
"backup_hint": "사전을 JSON 파일로 다운로드하거나 이전 내보내기에서 항목을 가져옵니다.",
"export": "JSON 내보내기",
"import": "JSON 가져오기",
"import_replace_prompt": "기존 {{count}}개 항목을 가져온 파일로 바꾸시겠습니까? '아니요'를 선택하면 기존 항목에 추가됩니다.",
"import_done": "{{count}}개 항목을 가져왔습니다.",
"import_error": "해당 파일을 가져올 수 없습니다 — 발음 내보내기의 JSON이 필요합니다.",
"export_error": "사전을 내보낼 수 없습니다."
},
"audiobook": {
"title": "Audiobook",
+19 -7
View File
@@ -66,8 +66,8 @@
"title": "Instellingen",
"ui_scale": "UI-schaal",
"proxy": "Proxy",
"proxy_desc": "HTTP/SOCKS5-proxy voor downloads (yt-dlp, HuggingFace). Ondersteunt http://, https://, sokken5://. Opnieuw opstarten vereist indien gewijzigd na het starten van de backend.",
"proxy_saved": "Proxyset — yt-dlp zal het gebruiken voor downloads",
"proxy_desc": "HTTP/SOCKS5-proxy voor downloads (videodownloads, Hugging Face). Ondersteunt http://, https://, socks5://. Geldt direct voor nieuwe downloads.",
"proxy_saved": "Proxy ingesteld — nieuwe downloads gebruiken deze",
"proxy_save_failed": "Kan proxy niet opslaan",
"proxy_cleared": "Proxy gewist",
"proxy_clear": "Duidelijk",
@@ -153,6 +153,8 @@
"translation_providers": "Vertaalaanbieders",
"translation_providers_desc": "API-sleutels voor online vertalers, ingesteld voor deze sessie.",
"review_mode": "Review-modus",
"review_mode_on": "Beoordeling",
"review_mode_off": "Snelvuur",
"review_mode_desc": "Pauzeer tussen pijplijnfasen, zodat u de ASR-/vertaaluitvoer kunt bekijken.",
"header_live_stats": "Toon live systeemstatistieken in de koptekst",
"header_live_stats_desc": "Voegt een live RAM/CPU/VRAM-monitor toe aan de bovenste balk (standaard uitgeschakeld).",
@@ -465,7 +467,6 @@
"check_updates": "Controleer op updates",
"copy_diagnostics": "Diagnostische gegevens kopiëren",
"github": "OmniVoice op GitHub",
"model_card": "Modelkaart",
"commercial_license": "Commerciële licentie",
"update_channel": "Updatekanaal",
"channel_stable": "Stabiel",
@@ -527,8 +528,6 @@
"group_microsoft": "Aangepast eindpunt van Microsoft Translator"
},
"engines": {
"review_on": "Beoordeling",
"review_off": "Snelvuur",
"banners_on": "Podiumbanners aan",
"banners_off": "Podiumbanners uit",
"backend": "Achterkant",
@@ -1997,14 +1996,27 @@
"enabled": "Ingeschakeld",
"add": "Vermelding toevoegen",
"remove": "Verwijder {{term}}",
"empty": "Nog geen vermeldingen. Voeg er hierboven één toe om een verkeerd uitgesproken woord te corrigeren.",
"empty": "Nog geen vermeldingen. Voeg er hieronder één toe om een verkeerd uitgesproken woord te corrigeren.",
"test_label": "Test een zin",
"test_placeholder": "Typ een zin om een voorbeeld van de vervanging te bekijken...",
"test_result": "Zal worden uitgesproken als:",
"test_nochange": "Er komen geen overeenkomende vermeldingen overeen - gesproken zoals geschreven.",
"load_error": "Kan het uitspraakwoordenboek niet laden.",
"save_error": "Kan die invoer niet opslaan.",
"lang_label": "Taalcode (bijvoorbeeld en, de) of laat leeg voor Globaal"
"lang_label": "Taalcode (bijvoorbeeld en, de) of laat leeg voor Globaal",
"enable_entry": "{{term}} inschakelen",
"lang_placeholder": "en, de… (leeg = Globaal)",
"test_language": "Voorbeeldtaal",
"test_global_hint": "Voorbeeld met alleen globale vermeldingen — kies een taal om taalspecifieke vermeldingen mee te nemen.",
"test_error": "Voorbeeld niet beschikbaar — de backend was niet bereikbaar.",
"backup_title": "Back-up & herstel",
"backup_hint": "Download het woordenboek als JSON-bestand, of importeer vermeldingen uit een eerdere export.",
"export": "JSON exporteren",
"import": "JSON importeren",
"import_replace_prompt": "De {{count}} bestaande vermeldingen vervangen door het geïmporteerde bestand? Kies Nee om de geïmporteerde vermeldingen ernaast toe te voegen.",
"import_done": "{{count}} vermeldingen geïmporteerd.",
"import_error": "Kan dat bestand niet importeren — JSON van een uitspraak-export verwacht.",
"export_error": "Kan het woordenboek niet exporteren."
},
"audiobook": {
"title": "Audioboek",

Some files were not shown because too many files have changed in this diff Show More