Compare commits

..
23 Commits
Author SHA1 Message Date
2a22952f9c release: freeze v0.3.18 — version bump, lockfiles, changelog (#1084)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 02:01:26 +05:30
ff56865cf7 feat(engines): one-click IndexTTS-2 sidecar install from Settings → Engines (#1083)
* feat(engines): one-click IndexTTS-2 sidecar install from Settings → Engines

IndexTTS-2 required four manual terminal steps (git clone, uv venv,
uv pip install -e ., export OMNIVOICE_INDEXTTS_DIR). This turns that into
a guided in-app install:

- backend/services/sidecar_install.py — parametrized sidecar provisioner
  (SidecarSpec/SPECS so future sidecar engines are one entry, not another
  installer). Resumable background job with step-by-step status: disk-space
  preflight (needs-X/have-Y message), source fetch (git clone --depth 1
  primary, GitHub tarball fallback when git is absent/fails), dedicated
  venv via uv (OMNIVOICE_BUNDLED_UV → PATH resolution; transformers<5
  isolation preserved — the parent env is never touched), import-probe
  verification, IndexTeam/IndexTTS-2 weights into <checkout>/checkpoints
  (where the sidecar actually loads from) via snapshot_download with the
  auto-selected/configured HF endpoint + token — no hardcoded
  huggingface.co — and persistence of OMNIVOICE_INDEXTTS_DIR (os.environ
  for immediate use, prefs.json env.* for the next launch). Idempotent:
  partial installs repair, downloads resume, healthy installs (incl. a
  user's own clone) report already_installed and are never touched.
- API: POST /engines/{id}/install starts the job, GET
  /engines/{id}/install/status polls it, DELETE /engines/{id}/install
  removes an app-managed install (loopback-gated; refuses user-managed
  clones). list_backends() gains one_click_install.
- Frontend: Settings → Engines shows an Install button on the IndexTTS2
  row with per-step progress, live log tail, weight-download %, and
  error+remediation; the manual setup snippet is demoted to a collapsed
  "Manual install" fallback. All strings via i18n (en.json).
- OMNIVOICE_INDEXTTS_DIR joins the Settings env-var allowlist
  (single-sourced from the installer SPECS).
- Docs: docs/engines/indextts.md leads with the one-click flow; manual
  steps become the fallback section. CHANGELOG Unreleased entry added.
- Tests: tests/test_sidecar_install.py (24 cases — happy path, disk-space
  fail, git-absent/git-failing tarball fallback, partial-install repair,
  already-installed/running gating, uninstall safety, spec↔bootstrap
  contract, router wiring) + 6 new EngineCompatibilityMatrix RTL cases.
  API route snapshot regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engines): harden the sidecar installer — review findings

- Route namespace: /engines/sidecar/{id}/install — a dynamic
  /engines/{id}/install would shadow the literal
  POST /engines/sonitranslate/install (engines router registers first);
  regression-guarded by test_sidecar_routes_never_shadow_literal_engine_routes.
- Weights completion marker: a killed-mid-download multi-shard weights dir
  (config.yaml + plausible shards) no longer passes for healthy; the marker
  is written only after snapshot_download returns, so re-runs resume.
- _run_logged: drain thread + proc.wait(timeout) + POSIX process-group kill
  — a grandchild holding the stdout pipe can no longer hang the step past
  its timeout.
- Job log lock: the status poll's list(deque) copy no longer races the
  worker's appends (RuntimeError under active logging).
- Self-heal: a healthy managed install whose env var was lost (prefs wiped)
  is re-pointed by start_install instead of reported already_installed
  while the engine stays unavailable; legacy bootstrap installs (Probe-2
  venv) are trusted via the engine's own probe.
- Single-sourced uv/venv-layout resolution: engines.indextts.bootstrap now
  delegates _locate_uv/_venv_python_path to services.sidecar_install.
- Frontend: stable poll interval (keyed on the running-id set, not the
  status map), reload on a job that finishes before the first poll,
  re-attach to an in-flight job on remount, i18n'd Install aria-label,
  manual-install <details> auto-opens on failure, snippet block hoisted
  out of the JSX IIFE.
- list_backends: sidecar-installable set hoisted out of the per-engine
  loop; exhaustive-shape registry test updated for one_click_install.
- Tests rebind the live services.sidecar_install module per test (other
  suites purge sys.modules["services"], which made router tests
  order-dependent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): fill in the PR ref (#1083)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): validated tarball fallback + scanner-clean installer

- The pre-filter= extractall fallback (Python < 3.11.4) now extracts
  member-by-member behind the same guards extractall(filter="data")
  enforces — regular files/dirs only, no absolute paths, no ../ escapes,
  resolved-path containment. Kills the new CodeQL py/tarslip (high) and
  Bandit B202 (error) alerts; regression-tested with a malicious tarball
  (test_safe_extract_members_blocks_tar_slip).
- snapshot_download tracks the weights repo's default branch on purpose
  (same policy as every other model download; artifacts are
  checksum-verified by hf_hub) — documented + B615 waived at the call.
- Explanatory comments on the intentional empty-except blocks
  (CodeQL py/empty-except notes).

Verified locally: bandit -ll -ii on the module reports 0 MEDIUM+ findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engines): address Greptile review — Windows tree kill, prefs write race, poll robustness

- _kill_tree: Windows now uses taskkill /F /T so a git/uv helper spawned by
  the timed-out child can't keep writing into the checkout (POSIX already
  killed the process group). Unit-tested with os.name patched to nt.
- core/prefs: mutations (set_/delete) are serialized behind a module lock —
  the installer worker persisting its env.* key concurrently with a Settings
  write could previously drop whichever key saved first (whole-class fix:
  every threaded prefs writer, not just the installer). Fail-before/
  pass-after: tests/test_prefs_thread_safety.py.
- Matrix polling: at most one in-flight status request per engine (an old
  'running' response can no longer land after a newer 'succeeded' and
  restart the poller), and four consecutive poll failures drop the stale
  snapshot instead of showing "Installing…" and hammering a dead backend
  forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:13:35 +05:30
9c81e3389d feat(network): automatic Hugging Face endpoint selection — probe, pick, remember (#1082)
* feat(network): automatic Hugging Face endpoint selection — probe, pick, remember

Restricted-network first-runs (the #984 class: huggingface.co unreachable,
user dead-ends before discovering the mirror setting) now self-heal by
default, while explicit endpoint choices are never second-guessed.

- New backend/services/endpoint_race.py: parallel HTTPS reachability +
  latency probes of huggingface.co and the hf-mirror.com community mirror
  (3s timeouts). Probes are the only signal — no geo-IP, no third-party
  calls. Reachable beats unreachable; with both reachable the official
  endpoint wins unless the mirror is decisively faster (anti-flap
  hysteresis). The pick is cached in prefs and re-raced only on first run,
  a network-classified download failure, staleness (>7 days), or an
  explicit "Test again".
- Manual mode is sacred: HF_ENDPOINT env, an hf_endpoint pref, or any
  explicit Settings pick disables auto-switching entirely;
  OMNIVOICE_HF_ENDPOINT_MODE=manual is a hard opt-out.
- Wiring: the wizard preflight races endpoints when nothing is configured
  (honest copy when the mirror wins; warn-not-block when nothing is
  reachable); Model Store installs and the model-cache auto-repair resolve
  their per-call endpoint= through the cached decision, and a
  network-classified failure re-races once per repo per process and
  retries on the new winner (same guard pattern as the cache-recovery
  ladder).
- Settings → Models → Hugging Face mirror gains "Auto (recommended)":
  shows the current pick, measured latency, last-checked time, and a
  "Test again" button (POST /api/settings/hf-mirror/test). Existing
  explicit configs surface as the matching manual mode. Panel notes that
  hf_hub checksums every download regardless of endpoint.
- Tests: policy/cache/failover matrices in tests/test_endpoint_race.py,
  preflight + settings + repair-failover integration with mocked probers,
  HFMirrorPanel mode tests, and a suite-wide conftest guard that pins the
  probers so no test can hit the real network.
- Docs: downloading-models.md and install/troubleshooting.md describe the
  automatic default and both opt-outs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): Unreleased entry for automatic HF endpoint selection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): endpoint-probe pin uses an isolated MonkeyPatch and clears the decision cache; dtype guard tolerates stubbed torch

The autouse probe pin requested the shared monkeypatch fixture, hoisting
its setup earlier for every test and reordering teardown against the fp16
guard — which then ran torch.get_default_dtype() on test_torch_compile_gate's
SimpleNamespace stub. The pin now uses its own MonkeyPatch context and also
clears the prefs-cached endpoint decision per test (one test's auto pick
leaked into other tests' preflight labels on CI ordering). The dtype guard
additionally skips non-module torch stubs outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): endpoint env vars can no longer leak out of the mirror-settings suite

set_hf_mirror writes os.environ[HF_ENDPOINT] during the test, and
monkeypatch.delenv(raising=False) on an absent var records nothing to
undo — so the write leaked process-wide and flipped later suites'
preflight network checks into the explicit-endpoint branch (the CI-order
failures). Guaranteed save/restore autouse fixture at the source, plus
defensive env shedding in the preflight suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:44:21 +05:30
3f62471bad test: pay down export-router test debt + kill two test-order pollution classes (#1081)
Four pieces of test debt, root-caused and hardened:

1. exports.py test coverage (was: zero dedicated tests): new
   tests/test_exports_api.py (26 tests) covering /export, /export/record,
   /export/history, /export/reveal — happy paths, traversal/containment
   guards (incl. symlink escape), destination validation, error mapping,
   and the mp4 watermark-overlay branch with its plain-copy fallback.
   Two real bugs found and fixed in the router:
   - _safe_destination checked isabs() on realpath()'s output, which is
     always absolute — dead check; a relative destination silently exported
     to a cwd-dependent location instead of the documented 400.
   - _safe_source let "." / ".." through the basename guard (caught only
     later by realpath containment as a confusing 404); now 400 up front.

2. CI-Linux fp16 default-dtype leak (test_prefers_vocals_over_mix,
   test_final_dub_track_and_seg_wav_are_watermarked): not reproducible on
   macOS — instrumenting torch.set_default_dtype across both tests records
   zero non-fp32 sets locally. Both tests now carry an opt-in
   torch_dtype_isolation fixture (save/restore, so the leak can never
   spread), and the conftest guard is demoted to pure insurance. A cheap
   permanent recorder wraps torch.set_default_dtype /
   set_default_tensor_type once torch appears and captures the setter's
   stack only on a non-fp32 set; both fixtures print that stack when they
   fire, so the next CI occurrence names the exact culprit call chain.

3. Test-order pollution (both reported combos): root cause was
   collection-time sys.modules stubbing in backend/tests — seven modules
   installed bare ModuleType stubs for core.config (and test_capture_ws.py
   for services.model_manager/asr_backend/ffmpeg_utils, now all lazily
   imported by the router anyway). pytest imports test modules during
   collection, so the stubs leaked process-wide before any test ran:
   - combo (a): monkeypatch.setattr("core.config.OUTPUTS_DIR", ...) in
     test_longform_e2e died with AttributeError (core never gets a .config
     attribute when the import is satisfied straight from sys.modules).
   - combo (b): test_router_smoke's `from main import app` died with
     ImportError: cannot import name 'find_ffmpeg' (unknown location).
   Fix at source: new backend/tests/conftest.py sets a hermetic
   OMNIVOICE_DATA_DIR (mirroring tests/conftest.py, #878) and the real
   core.config is imported everywhere — zero sys.modules surgery. New
   backend/tests/test_no_module_stubs.py guards the whole class (verified
   fail-before/pass-after against the old stub). Stale rationale comments
   in pyproject.toml and ci.yml updated to match.

4. batched_tts.py TODO(#312): investigated, comment corrected only —
   #312 is closed (the live routes are engine-aware); this module has zero
   call sites and stays an unintegrated experiment. See PR notes.

Full tests/ suite: 2796 passed. backend/tests standalone: 130 passed.
Both pollution combos re-run green in the reported orderings.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:10:35 +05:30
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
175 changed files with 13932 additions and 1546 deletions
+4 -3
View File
@@ -74,9 +74,10 @@ jobs:
- name: Validate install docs against desktop-prod.sh
run: python scripts/validate-install-docs.py
# `backend/tests/` stubs core.config in sys.modules to avoid the heavy
# main app import chain — that pollutes import state for other modules,
# so it runs in its own pytest session to stay isolated from tests/.
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
# import chain) with a hermetic data dir from its conftest.py. It no
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
# the separate session is kept for cheaper, clearer CI output.
- name: Run pytest (backend/tests, isolated)
run: uv run pytest backend/tests/ -q --tb=short
+40
View File
@@ -6,6 +6,46 @@ 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.18] — 2026-07-12
The self-sufficiency release. Two long-standing "works on my network / works after four terminal commands" walls came down: model downloads now find a reachable Hugging Face endpoint on their own (no more restricted-network first-run dead-ends), and IndexTTS-2 — previously the only engine that demanded a manual clone-venv-install ritual — installs itself with one click. Under the hood, a test-debt sweep hardened the suite that guards all of it.
### Added
- **IndexTTS-2 installs itself now — one click in Settings → Engines.** The emotion-controlled cloning engine used to demand four terminal steps (clone the repo, create a venv, `uv pip install`, set an environment variable); the row now has an Install button that does all of it — source fetch (git, with a no-git tarball fallback), an isolated venv that keeps its `transformers<5` away from the app, the ~6 GB model weights (via your configured/auto-selected Hugging Face endpoint), and configuration — with step-by-step progress, a disk-space check before anything is written, and resumable repair if anything is interrupted. The engine is usable the moment the job finishes, no restart; existing manual installs are detected and left untouched, and the manual steps remain as a collapsible fallback. The provisioner is parametrized so future sidecar engines (MOSS-v1.5, dots.tts, Confucius4) can reuse it. (#1083)
- **Model downloads now find a reachable Hugging Face endpoint on their own.** On networks where huggingface.co is blocked or slow (the class of first-run dead-ends behind #984), the app quietly probes the official endpoint and the hf-mirror.com community mirror, picks whichever actually works, remembers the choice, and re-checks only when a download fails or the pick goes stale — so a restricted-network first run reaches a working voice instead of a wall of connection errors. Anyone who already set a mirror (env var, pref, or Settings) stays exactly where they pointed: explicit choices are never auto-switched, and Settings → Models → Hugging Face mirror now shows the automatic pick with its measured latency plus a "Test again" button. Probes only touch the two download hosts — no geo-IP, no telemetry — and every download stays checksum-verified by `huggingface_hub` regardless of endpoint. (#1082)
### Fixed
- **Concurrent settings writes can no longer drop each other.** Two parts of the app saving preferences at the same moment (say, an engine install finishing while you change a setting) could silently lose whichever save landed first; preference writes are now serialized, with a regression test. Found and fixed as part of the IndexTTS-2 installer work. (#1083)
### CI
- **Test-suite debt sweep.** Exports coverage (26 new tests, which caught two real router bugs), fp16 default-dtype leak instrumentation, and a suite-order pollution class root-caused at its source — the checks that guard every release got stricter. (#1081)
## [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.
+91
View File
@@ -156,6 +156,97 @@ async def uninstall_translation_engine(engine_id: str):
return {"status": "uninstalled", "engine": engine_id, "package": pkg, "log_tail": out[-800:]}
# ── One-click sidecar-engine install (IndexTTS-2 & friends) ────────────────
#
# Sidecar engines (dedicated venv + source checkout + weights, isolated from
# the parent's transformers>=5.3) used to require four manual terminal steps.
# These routes drive services.sidecar_install: POST starts a resumable
# background job, GET polls its step-by-step status (the Settings → Engines
# Install button polls this), DELETE removes an app-managed install.
#
# Path namespace: /engines/sidecar/{engine_id}/… — NOT /engines/{engine_id}/…
# — because a dynamic segment there would shadow pre-existing literal routes
# (this router registers before sonitranslate's, so a dynamic
# POST /engines/{engine_id}/install would swallow
# POST /engines/sonitranslate/install). Mirrors the
# /engines/translation/{engine_id}/install namespace pattern.
#
# Loopback-gated: installing spawns subprocesses (git/uv) and writes to the
# data directory — only the local desktop frontend may trigger it. The job
# runs fine in packaged builds: the venv lives under the user data dir, not
# inside the signed app bundle, and uv resolves via OMNIVOICE_BUNDLED_UV/PATH.
@router.post(
"/engines/sidecar/{engine_id}/install",
dependencies=[Depends(require_loopback)],
)
def install_sidecar_engine(engine_id: str):
"""Start (or report) the one-click install for a sidecar engine.
Returns ``{status: "started"|"already_running"|"already_installed"}``.
404 for engines that have no sidecar installer — the response names the
translation-engine route so a mis-aimed client can self-correct.
"""
from services import sidecar_install
try:
return sidecar_install.start_install(engine_id)
except KeyError:
raise HTTPException(
status_code=404,
detail=(
f"No one-click installer for engine {engine_id!r}. Sidecar "
f"installers exist for: {sorted(sidecar_install.SPECS)}. "
"(Translation engines install via POST "
"/engines/translation/{id}/install.)"
),
)
@router.get(
"/engines/sidecar/{engine_id}/install/status",
dependencies=[Depends(require_loopback)],
)
def sidecar_install_status(engine_id: str):
"""Step-by-step status of the sidecar install job (poll while running).
Shape: ``{engine_id, installed, managed, install_dir, job}`` where job is
null before the first run, else ``{state, steps[], log[], error,
remediation, weights_progress, started_at, finished_at}``.
"""
from services import sidecar_install
try:
return sidecar_install.get_status(engine_id)
except KeyError:
raise HTTPException(
status_code=404,
detail=f"No one-click installer for engine {engine_id!r}.",
)
@router.delete(
"/engines/sidecar/{engine_id}/install",
dependencies=[Depends(require_loopback)],
)
def uninstall_sidecar_engine(engine_id: str):
"""Remove an app-managed sidecar install (checkout + venv + weights) and
clear the persisted path. Refuses user-managed installs (a clone the user
made themselves) and installs with a job still running."""
from services import sidecar_install
try:
res = sidecar_install.uninstall(engine_id)
except KeyError:
raise HTTPException(
status_code=404,
detail=f"No one-click installer for engine {engine_id!r}.",
)
if res["status"] == "install_in_progress":
raise HTTPException(status_code=409, detail="Install is still running — wait for it to finish.")
if res["status"] == "not_managed":
raise HTTPException(status_code=400, detail=res["detail"])
return res
# ── Engine health-check (Plan 02-04 / ENGINE-06) ───────────────────────────
#
# The Compat Matrix UI's "Test engine" button calls into this endpoint so
+11 -3
View File
@@ -21,12 +21,17 @@ def _safe_destination(raw: str) -> str:
status_code=400,
detail="Export needs a destination folder. Pick where the file should go and try again.",
)
dest = os.path.realpath(os.path.expanduser(raw))
if not os.path.isabs(dest):
expanded = os.path.expanduser(raw)
# Check BEFORE realpath(): realpath absolutizes a relative path against
# the server's cwd, which made this check dead code — a relative
# destination silently exported to a cwd-dependent location instead of
# the documented 400 (regression-tested in tests/test_exports_api.py).
if not os.path.isabs(expanded):
raise HTTPException(
status_code=400,
detail="The destination needs to be a full path (e.g. /Users/you/Movies/OmniVoice) — not relative.",
)
dest = os.path.realpath(expanded)
parent = os.path.dirname(dest)
if not parent or not os.path.isdir(parent):
raise HTTPException(
@@ -39,7 +44,10 @@ def _safe_destination(raw: str) -> str:
def _safe_source(filename: str) -> str:
"""Resolve a source filename against OUTPUTS_DIR / dub outputs, blocking traversal."""
base = os.path.basename(filename or "")
if not base or base != filename:
# "." and ".." are their own basename, so they'd slip past the
# base != filename check and only die later on realpath containment —
# reject them up front with the same 400 as any other malformed name.
if not base or base != filename or base in (".", ".."):
raise HTTPException(
status_code=400,
detail="The file to export has an unexpected name. Try re-generating the audio and exporting again.",
+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))
+100 -6
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
@@ -744,27 +773,63 @@ _HF_MIRROR_PRESETS = [
class _HFMirrorBody(BaseModel):
url: str = Field("", description="HF_ENDPOINT URL; empty string clears it (official endpoint)")
mode: str | None = Field(
None,
description=(
"'auto' switches to automatic endpoint selection (clears any "
"explicit endpoint); 'manual' (or omitted — back-compat with older "
"clients) pins the given url as an explicit choice."
),
)
@router.get("/hf-mirror")
def get_hf_mirror():
def _hf_mirror_state() -> dict:
"""The full GET /hf-mirror payload. Auto info comes from the CACHED race
decision only reading settings never probes the network."""
from core import user_env
from services import endpoint_race
configured = user_env.get_user_env(_HF_ENDPOINT_ENV) or ""
try:
mode = "manual" if configured else endpoint_race.mode()
auto = endpoint_race.cached_decision() if mode == "auto" else None
opt_out = endpoint_race.env_opt_out()
except Exception: # a broken prefs file must never 500 the settings page
logger.exception("hf-mirror auto state unavailable")
mode, auto, opt_out = "manual", None, False
return {
# The value that will apply after restart (persisted), and what's
# live in this process (env may differ until then).
"configured": configured,
"effective": os.environ.get(_HF_ENDPOINT_ENV, ""),
"presets": _HF_MIRROR_PRESETS,
# Automatic endpoint selection (services.endpoint_race): "auto" only
# when nothing explicit is configured anywhere. `auto` is the cached
# race decision ({endpoint, reachable, latency_ms, checked_at,
# results}) or null when never raced / in manual mode.
"mode": mode,
"auto": auto,
"auto_opt_out": opt_out,
}
@router.get("/hf-mirror")
def get_hf_mirror():
return _hf_mirror_state()
@router.put("/hf-mirror")
def set_hf_mirror(body: _HFMirrorBody):
from core import user_env
from services import endpoint_race
url = (body.url or "").strip().rstrip("/")
mode = (body.mode or "manual").strip().lower()
if mode not in {"auto", "manual"}:
raise HTTPException(status_code=400, detail="mode must be 'auto' or 'manual'")
# Auto mode = no explicit endpoint anywhere; a persisted endpoint would
# read as an explicit choice, so switching to Auto clears it (plus the
# `hf_endpoint` pref fallback the download paths resolve).
url = "" if mode == "auto" else (body.url or "").strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Mirror URL must start with http(s)://")
# Compare against the currently-persisted value (normalised the same way) so
@@ -779,15 +844,44 @@ def set_hf_mirror(body: _HFMirrorBody):
else:
user_env.unset_user_env(_HF_ENDPOINT_ENV)
os.environ.pop(_HF_ENDPOINT_ENV, None)
from core import prefs
if mode == "auto":
prefs.delete("hf_endpoint") # the pref fallback is explicit config too
endpoint_race.set_mode_pref(mode)
except Exception:
logger.exception("set_hf_mirror failed")
raise HTTPException(status_code=500, detail="Failed to persist mirror setting")
if mode == "auto":
# Freshly chosen Auto should show a real pick immediately — race now
# unless a fresh cached decision already exists (probes are ≤3 s and
# this is an explicit user action, not a hot path).
try:
endpoint_race.ensure_decision()
except Exception:
logger.exception("endpoint race after switching to auto failed")
# Model Store downloads pick up the new mirror immediately — the download
# path resolves the endpoint per-call and we updated os.environ above. Only
# transformers-side model *loads* (which read HF_ENDPOINT at import time)
# need a restart, so restart_required is True ONLY when the value actually
# changed — a no-op re-save never asks for a restart.
return {"configured": url, "restart_required": changed, "presets": _HF_MIRROR_PRESETS}
return {**_hf_mirror_state(), "restart_required": changed}
@router.post("/hf-mirror/test")
def test_hf_mirror():
"""Re-run the endpoint race now (the Auto panel's "Test again").
Forces fresh probes and re-caches the decision. In manual mode this is a
no-op (an explicit endpoint is never auto-switched) the response simply
reflects the current state."""
from services import endpoint_race
try:
endpoint_race.ensure_decision(force=True)
except Exception:
logger.exception("hf-mirror endpoint test failed")
raise HTTPException(status_code=500, detail="Endpoint test failed")
return _hf_mirror_state()
# ── OpenAI-compatible remote ASR (#877) ─────────────────────────────────────
+24 -4
View File
@@ -72,12 +72,15 @@ def _download_max_workers() -> int:
def _download_endpoint() -> "str | None":
"""Optional HF endpoint override (FDL-10 mirror path, opt-in). Returned as a
per-call ``endpoint=`` rather than a process-wide HF_ENDPOINT mutation. A
"""Optional HF endpoint override, per-call ``endpoint=`` rather than a
process-wide HF_ENDPOINT mutation. Explicit configuration (FDL-10 mirror
path: HF_ENDPOINT env / ``hf_endpoint`` pref / Settings) always wins; when
nothing was chosen, the automatic endpoint selection's cached pick applies
(services.endpoint_race probe-based, cached, never probes here). A
mirror routes through the classic LFS path (no Xet) documented in
docs/downloading-models.md."""
ep = prefs.resolve("hf_endpoint", env="HF_ENDPOINT", default=None)
return ep or None
from services import endpoint_race
return endpoint_race.effective_endpoint()
def apply_xet_env() -> None:
@@ -488,6 +491,23 @@ async def install_model(req: InstallModelRequest):
"model install %s: attempt %d/%d failed (%s); retry in %ds",
req.repo_id, _attempt, _max_attempts, net_err, _backoff,
)
# Endpoint failover (auto mode only, once per repo per
# process): a network-classified failure re-races the
# endpoints and, when the winner changed, the next attempt
# retries on it — so a mid-download endpoint outage heals
# instead of burning every retry on a dead host. Explicit
# user endpoints are never switched.
from services import endpoint_race
if endpoint_race.reselect_after_failure(req.repo_id, str(net_err)):
_endpoint = _download_endpoint()
if _endpoint:
dl_kwargs["endpoint"] = _endpoint
else:
dl_kwargs.pop("endpoint", None)
logger.info(
"model install %s: endpoint failover — retrying on %s",
req.repo_id, _endpoint or "https://huggingface.co",
)
hf_progress.emit({
"repo_id": req.repo_id,
"filename": req.repo_id,
+139 -98
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
@@ -202,6 +203,114 @@ def _hf_endpoint_host() -> tuple[str, int]:
return "huggingface.co", 443
def _network_check() -> dict:
"""The preflight "network" check row — auto-race or explicit-endpoint probe.
Auto mode (nothing explicitly configured): force a fresh endpoint race
preflight IS the connectivity health check, and the cached winner is what
model downloads will use. Manual mode: probe exactly the configured
endpoint (never auto-switch an explicit choice), keeping the mirror
quick-pick affordance when the official endpoint is blocked.
"""
auto_decision = None
try:
from services import endpoint_race
if endpoint_race.mode() == "auto":
auto_decision = endpoint_race.ensure_decision(force=True)
except Exception as exc: # the race must never break preflight
logger.warning("preflight endpoint race failed: %s", exc)
if auto_decision is not None:
from urllib.parse import urlsplit
from services.endpoint_race import CANONICAL_ENDPOINT
picked = auto_decision["endpoint"]
picked_host = urlsplit(picked).hostname or picked
latency = auto_decision.get("latency_ms")
latency_s = f" ({latency:.0f} ms)" if isinstance(latency, (int, float)) else ""
results = {r["endpoint"]: r for r in auto_decision.get("results", [])}
canonical_ok = bool(results.get(CANONICAL_ENDPOINT, {}).get("reachable"))
mirror_reachable = any(
r.get("reachable") for ep, r in results.items() if ep != CANONICAL_ENDPOINT
)
if auto_decision.get("reachable"):
if picked == CANONICAL_ENDPOINT:
detail = f"Reachable{latency_s}"
elif not canonical_ok:
detail = (
f"huggingface.co is unreachable on this network — using the "
f"community mirror {picked_host}{latency_s} for model "
"downloads. Downloads are checksum-verified by Hugging Face "
"regardless of endpoint; change anytime in Settings → "
"Models → Hugging Face mirror."
)
else:
detail = (
f"Both endpoints reachable — {picked_host}{latency_s} "
"selected (decisively faster here). Change anytime in "
"Settings → Models → Hugging Face mirror."
)
status, fix = "pass", None
else:
status = "warn"
detail = "No Hugging Face endpoint reachable"
fix = (
"Neither huggingface.co nor the hf-mirror.com community mirror "
"responded — check internet connection, VPN, or firewall. You "
"can continue — models already downloaded keep working "
"offline; a custom mirror can be configured below."
)
return {
"id": "network", "label": f"Network ({picked_host})",
"status": status, "detail": detail, "fix": fix,
# Frontend affordance hint: the wizard offers the mirror
# quick-pick when the check didn't pass (PreflightCheck allows
# extras). `endpoint` documents the auto pick for the UI.
"mirror_reachable": mirror_reachable,
"endpoint": picked,
}
# Manual mode (explicit endpoint) — probe exactly what the user chose.
net_host, net_port = _hf_endpoint_host()
net_ok = _probe_network(net_host, net_port)
mirror_reachable = False
if not net_ok and net_host == "huggingface.co":
# Official endpoint blocked — if the community mirror is reachable,
# tell the user exactly which switch unblocks them.
mirror_reachable = _probe_network("hf-mirror.com")
if net_ok:
net_fix = None
elif mirror_reachable:
net_fix = (
"huggingface.co is blocked on this network, but the hf-mirror.com "
"community mirror is reachable — apply it below and re-check. "
"Model downloads will use the mirror immediately."
)
elif net_host != "huggingface.co":
net_fix = (
f"Your configured Hugging Face mirror ({net_host}) is unreachable "
"— it may be down or blocked. Pick another mirror or the official "
"endpoint below, or continue offline: models already downloaded "
"keep working."
)
else:
net_fix = (
"Check internet connection, VPN, or corporate firewall whitelist "
"for huggingface.co. You can continue — models already downloaded "
"keep working offline; new downloads need a connection or a "
"mirror (configurable below)."
)
return {
"id": "network", "label": f"Network ({net_host})",
"status": "pass" if net_ok else "warn",
"detail": "Reachable" if net_ok else f"Unreachable on port {net_port}",
"fix": net_fix,
# Frontend affordance hint: the wizard offers the mirror quick-pick
# when the endpoint is unreachable (PreflightCheck allows extras).
"mirror_reachable": mirror_reachable,
}
def _ram_gb() -> float:
try:
import psutil
@@ -280,59 +389,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()
@@ -424,50 +493,21 @@ def preflight():
"status": r_status, "detail": r_detail, "fix": r_fix,
})
# ── Network — probes the HF endpoint actually in effect (mirror-aware),
# and a dead network is a WARNING, not a blocker. The app is local-first:
# already-downloaded models work offline, and a hard fail here dead-ends
# restricted-network users (e.g. China, where huggingface.co is blocked)
# on the very first screen — before they can reach the mirror setting
# that fixes it. Model downloads surface their own actionable errors.
net_host, net_port = _hf_endpoint_host()
net_ok = _probe_network(net_host, net_port)
mirror_reachable = False
if not net_ok and net_host == "huggingface.co":
# Official endpoint blocked — if the community mirror is reachable,
# tell the user exactly which switch unblocks them.
mirror_reachable = _probe_network("hf-mirror.com")
if net_ok:
net_fix = None
elif mirror_reachable:
net_fix = (
"huggingface.co is blocked on this network, but the hf-mirror.com "
"community mirror is reachable — apply it below and re-check. "
"Model downloads will use the mirror immediately."
)
elif net_host != "huggingface.co":
net_fix = (
f"Your configured Hugging Face mirror ({net_host}) is unreachable "
"— it may be down or blocked. Pick another mirror or the official "
"endpoint below, or continue offline: models already downloaded "
"keep working."
)
else:
net_fix = (
"Check internet connection, VPN, or corporate firewall whitelist "
"for huggingface.co. You can continue — models already downloaded "
"keep working offline; new downloads need a connection or a "
"mirror (configurable below)."
)
checks.append({
"id": "network", "label": f"Network ({net_host})",
"status": "pass" if net_ok else "warn",
"detail": "Reachable" if net_ok else f"Unreachable on port {net_port}",
"fix": net_fix,
# Frontend affordance hint: the wizard offers the mirror quick-pick
# when the endpoint is unreachable (PreflightCheck allows extras).
"mirror_reachable": mirror_reachable,
})
# ── Network — a dead network is a WARNING, not a blocker. The app is
# local-first: already-downloaded models work offline, and a hard fail
# here dead-ends restricted-network users (e.g. China, where
# huggingface.co is blocked) on the very first screen — before they can
# reach the mirror setting that fixes it. Model downloads surface their
# own actionable errors.
#
# With NO explicit endpoint configured, preflight runs the automatic
# endpoint race (services.endpoint_race): both the official endpoint and
# the community mirror are probed, the winner is cached for downloads,
# and the copy states the outcome honestly — so a blocked huggingface.co
# no longer needs the user to find the mirror setting at all. An explicit
# endpoint (Settings / HF_ENDPOINT / pref) keeps the single-endpoint
# probe: the user's choice is never auto-switched.
checks.append(_network_check())
# Aggregate
any_fail = any(c["status"] == "fail" for c in checks)
@@ -492,6 +532,7 @@ def preflight():
"disk_free_gb": round(free, 1),
},
"gpu_routing": gpu_routing,
"media_tools": media_tools,
}
+19 -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",
},
})
@@ -752,6 +754,17 @@ PERSISTENT_KEYS = {
"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT",
}
# Sidecar-engine install dirs (OMNIVOICE_INDEXTTS_DIR, …). The one-click
# installer persists these via prefs.json `env.*` (restored at startup in
# main.py); merging them here lets users inspect/clear them from the same
# Settings env panel as every other persisted var. Single-sourced from the
# installer's SPECS so a future sidecar engine can't forget to register.
try:
from services.sidecar_install import persistent_env_vars as _sidecar_env_vars
PERSISTENT_KEYS |= _sidecar_env_vars()
except Exception: # pragma: no cover — defensive: env panel > installer wiring
pass
# Keys whose value must be a valid TCP port (102465535). Validated before
# being set so a bad value never reaches uvicorn / the share listener.
_PORT_KEYS = {"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT"}
+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.",
)
+13 -1
View File
@@ -102,6 +102,18 @@ _HF_CONTEXT_MARKERS = (
)
def is_hf_connectivity_error(reason: Optional[str]) -> bool:
"""True when *reason* looks like a network/connectivity failure of an HF
download (DNS, refused/reset connections, timeouts, hub locate errors).
Signature match only callers already in a download context (the model
install worker, the cache auto-repair, the endpoint failover in
services.endpoint_race) don't need the HF-context markers that
``hf_mirror_hint`` requires. Never raises."""
low = (reason or "").lower()
return any(sig in low for sig in _HF_CONNECTIVITY_SIGNATURES)
def configured_hf_mirror() -> str:
"""The non-default Hugging Face endpoint (mirror) in effect, or "".
@@ -138,7 +150,7 @@ def hf_mirror_hint(reason: Optional[str]) -> str:
if not mirror:
return ""
low = (reason or "").lower()
if not any(sig in low for sig in _HF_CONNECTIVITY_SIGNATURES):
if not is_hf_connectivity_error(low):
return ""
try:
host = (urlsplit(mirror).netloc or "").lower()
+17 -6
View File
@@ -15,6 +15,7 @@ import json
import logging
import os
import tempfile
import threading
from typing import Any, Optional
from core.config import DATA_DIR
@@ -23,6 +24,14 @@ logger = logging.getLogger("omnivoice.prefs")
_PREFS_PATH = os.path.join(DATA_DIR, "prefs.json")
# Serializes the load-modify-save cycle of every mutation. Writers run on
# many threads (FastAPI's request threadpool, background workers like the
# sidecar-engine installer); without the lock two concurrent set_/delete
# calls interleave their read-modify-write and the later save silently
# drops the other's key. Reads stay lock-free — the atomic os.replace in
# _save guarantees they never see a torn file.
_MUTATE_LOCK = threading.RLock()
def _load() -> dict:
try:
@@ -60,16 +69,18 @@ def get(key: str, default: Any = None) -> Any:
def set_(key: str, value: Any) -> None:
data = _load()
data[key] = value
_save(data)
with _MUTATE_LOCK:
data = _load()
data[key] = value
_save(data)
def delete(key: str) -> None:
"""Remove *key* from prefs.json if present."""
data = _load()
data.pop(key, None)
_save(data)
with _MUTATE_LOCK:
data = _load()
data.pop(key, None)
_save(data)
def resolve(key: str, *, env: Optional[str] = None, default: Any = None) -> Any:
+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.18"
def _fallback_version() -> str:
+13 -14
View File
@@ -41,9 +41,7 @@ from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
@@ -145,11 +143,12 @@ def _venv_python_path(venv_dir: Path) -> Path:
"""Return the python executable path inside a venv directory.
Handles the Unix (``bin/python``) vs Windows (``Scripts/python.exe``)
layout. No filesystem access caller checks .is_file().
layout. No filesystem access caller checks .is_file(). Delegates to
the canonical implementation in :mod:`services.sidecar_install` so the
cross-platform venv-layout rule lives in exactly one place.
"""
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
from services.sidecar_install import _venv_python
return _venv_python(venv_dir)
def _probe_paths() -> list[Path]:
@@ -189,14 +188,14 @@ def _venv_can_import_indextts(python_path: Path) -> bool:
def _locate_uv() -> Optional[str]:
"""Find the uv binary — bundled first (Tauri-set env var), else PATH."""
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
if bundled and Path(bundled).is_file():
return bundled
sys_uv = shutil.which("uv")
if sys_uv:
return sys_uv
return None
"""Find the uv binary — bundled first (Tauri-set env var), else PATH.
Delegates to :mod:`services.sidecar_install`'s canonical resolver so the
bundled-uv contract (env var name, precedence) can't drift between this
lazy bootstrap and the one-click installer.
"""
from services.sidecar_install import _locate_uv as _canonical_locate_uv
return _canonical_locate_uv()
def _bootstrap_engines_venv(indextts_clone: Path) -> Path:
+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
+8 -3
View File
@@ -151,9 +151,14 @@ async def generate_segments_batched(
# Raw: skip all DSP — return raw model output
return audio_out
# TODO(#312): this route runs the OmniVoice model directly (not the active
# backend), so VoxCPM2 never reaches it. When these routes become
# engine-aware, guard with `if not getattr(backend, "applies_own_mastering", False)`.
# NOTE(#312, closed): the live routes (generation.py,
# dub_generate.py, batch.py, tts_stream.py) are engine-aware
# and honor `applies_own_mastering` themselves. This module
# still drives the OmniVoice model directly and currently has
# NO call sites — it's an unintegrated throughput experiment.
# If it is ever wired into a route, thread the active backend
# through instead of `model` and guard the mastering below
# with `if not getattr(backend, "applies_own_mastering", False)`.
mastered = apply_mastering(audio_out, sample_rate=sr)
effect_chain = get_effect_chain(seg_effect_preset)
if effect_chain:
+470
View File
@@ -0,0 +1,470 @@
"""Automatic Hugging Face endpoint selection — probe, pick, remember.
Restricted-network first-runs (e.g. China, where huggingface.co is blocked)
used to dead-end until the user found the mirror setting. This service makes
that class of failure self-healing: it *races* the official endpoint against
the community mirror with real connectivity probes and remembers the winner,
so model downloads work out of the box wherever at least one endpoint is
reachable.
Principles (owner-set):
- **Probes are the truth.** The decision comes only from actual reachability
and latency measurements against endpoints the app would legitimately
download from. Device locale/timezone is used *only* to order which
endpoint gets probed first never to decide. No geo-IP lookups, no
third-party calls, no telemetry.
- **Explicit choices are never auto-switched.** A user with an endpoint
configured anywhere (Settings Models, ``HF_ENDPOINT`` env, the
``hf_endpoint`` pref) is in manual mode; auto applies only where nothing
was chosen. ``OMNIVOICE_HF_ENDPOINT_MODE=manual`` is a hard env opt-out.
- **Sticky, canonical-first decisions.** With both endpoints reachable the
official endpoint wins unless the mirror is *decisively* faster
(``MIRROR_SPEEDUP_FACTOR``× on latency, confirmed by an optional small
ranged-GET throughput sample) so noise can't flap users onto a mirror.
The decision is cached in prefs and re-raced only on: no cached decision
(first run), a network-classified download failure, an explicit
"Test again", or a decision older than ``DECISION_MAX_AGE_S``.
- **Integrity is a non-issue.** huggingface_hub verifies every download by
etag/sha regardless of endpoint, so a mirror cannot silently corrupt
models.
Application is **per-call**: download paths (Model Store installs, the model
cache auto-repair) pass the effective endpoint as an ``endpoint=`` kwarg. The
auto decision is never written to ``HF_ENDPOINT``/user_env doing so would
make it indistinguishable from an explicit user choice.
Pure and mocked-transport-testable: ``race()`` takes injectable probers, and
tests patch the module-level ``probe_endpoint`` / ``throughput_probe``
(resolved at call time). Stdlib only.
"""
from __future__ import annotations
import logging
import os
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass
from typing import Callable, Optional
from urllib.parse import urlsplit
logger = logging.getLogger("omnivoice.endpoint_race")
CANONICAL_ENDPOINT = "https://huggingface.co"
COMMUNITY_MIRROR = "https://hf-mirror.com"
# Hard env opt-out: any of these values disables auto selection entirely.
MODE_ENV = "OMNIVOICE_HF_ENDPOINT_MODE"
_OPT_OUT_VALUES = {"manual", "off", "0", "false", "no"}
# prefs keys (core.prefs conventions: env > prefs.json > default).
_MODE_PREF = "hf_endpoint_mode" # "auto" | "manual"; absent → default
_DECISION_PREF = "hf_endpoint_auto" # cached decision dict (see race())
DECISION_MAX_AGE_S = 7 * 24 * 3600.0 # re-race a decision older than 7 days
PROBE_TIMEOUT_S = 3.0 # short: a probe is not a download
MIRROR_SPEEDUP_FACTOR = 3.0 # mirror must be ≥3× faster to win
# Small, stable, long-lived public file for the optional ranged-GET
# throughput tiebreak (mirrors proxy the same /resolve/ paths).
_THROUGHPUT_SAMPLE_PATH = "/openai-community/gpt2/resolve/main/model.safetensors"
_THROUGHPUT_SAMPLE_BYTES = 256 * 1024
# Serialises race+persist so concurrent callers can't double-race.
_race_lock = threading.Lock()
# Repos this process already re-raced for after a download failure — the
# failover may only happen ONCE per repo per process (same guard pattern as
# model_manager._LINK_REPAIR_ATTEMPTED) so a network that stays broken can't
# loop probe↔retry.
_FAILOVER_ATTEMPTED: set[str] = set()
@dataclass
class ProbeResult:
endpoint: str
reachable: bool
latency_ms: Optional[float] = None
error: str = "" # "", "timeout", "dns", "tls", "refused", "unreachable"
# ── Probes (the only network code in this module) ───────────────────────────
def _classify_probe_error(exc: Exception) -> str:
"""Coarse failure class for a probe, for logs/UI — never raises."""
import socket
import ssl
if isinstance(exc, (socket.timeout, TimeoutError)):
return "timeout"
if isinstance(exc, ssl.SSLError):
return "tls"
reason = getattr(exc, "reason", None)
if isinstance(reason, socket.gaierror):
return "dns"
if isinstance(reason, (socket.timeout, TimeoutError)):
return "timeout"
if isinstance(reason, ssl.SSLError):
return "tls"
if isinstance(exc, ConnectionRefusedError) or isinstance(reason, ConnectionRefusedError):
return "refused"
return "unreachable"
def probe_endpoint(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> ProbeResult:
"""HTTPS reachability + latency: one HEAD to the endpoint root.
Any HTTP response (even an error status) counts as reachable the probe
measures whether the network path works, not whether a specific resource
exists. Never raises."""
url = endpoint.rstrip("/") + "/"
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "OmniVoice-endpoint-probe"})
start = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout):
pass
except urllib.error.HTTPError:
pass # the server answered → reachable
except Exception as exc:
return ProbeResult(endpoint=endpoint, reachable=False, error=_classify_probe_error(exc))
return ProbeResult(
endpoint=endpoint,
reachable=True,
latency_ms=round((time.monotonic() - start) * 1000.0, 1),
)
def throughput_probe(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> Optional[float]:
"""Bytes/second over a small ranged GET of a stable public file, or None.
Used only as a tiebreak confirmation when latency says the mirror is
decisively faster throughput is what a multi-GB download actually
feels. Best-effort; any failure returns None (tiebreak skipped)."""
url = endpoint.rstrip("/") + _THROUGHPUT_SAMPLE_PATH
req = urllib.request.Request(
url,
headers={
"Range": f"bytes=0-{_THROUGHPUT_SAMPLE_BYTES - 1}",
"User-Agent": "OmniVoice-endpoint-probe",
},
)
deadline = time.monotonic() + timeout
total = 0
start = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
while total < _THROUGHPUT_SAMPLE_BYTES and time.monotonic() < deadline:
chunk = resp.read(min(65536, _THROUGHPUT_SAMPLE_BYTES - total))
if not chunk:
break
total += len(chunk)
except Exception:
return None
elapsed = max(time.monotonic() - start, 1e-6)
if total <= 0:
return None
return total / elapsed
# ── Locale/timezone probe-ORDER hint (stdlib only, never a decision) ────────
def _hint_sources() -> tuple[list[str], list[str]]:
"""(locale strings, timezone strings) from the environment — best-effort."""
locs: list[str] = []
for key in ("LC_ALL", "LC_MESSAGES", "LANG"):
v = os.environ.get(key)
if v:
locs.append(v)
try:
import locale as _locale
locs.extend(x for x in _locale.getlocale() if x)
except Exception:
pass
tzs: list[str] = []
tz_env = os.environ.get("TZ")
if tz_env:
tzs.append(tz_env)
try:
tzs.extend(x for x in time.tzname if x)
except Exception:
pass
return locs, tzs
_CN_TZ_NAMES = {"asia/shanghai", "asia/chongqing", "asia/urumqi", "asia/harbin"}
def cn_probe_hint(
locale_strings: Optional[list[str]] = None,
tz_strings: Optional[list[str]] = None,
) -> bool:
"""True when device language/region/timezone *suggests* mainland China.
Purely cosmetic: it reorders which endpoint gets probed first (so the
likely winner's result lands soonest); every candidate is always probed
and the decision comes from the probes alone (VPNs, expats, and corporate
networks make region a lie). Never raises."""
try:
if locale_strings is None or tz_strings is None:
env_locs, env_tzs = _hint_sources()
locale_strings = env_locs if locale_strings is None else locale_strings
tz_strings = env_tzs if tz_strings is None else tz_strings
for raw in locale_strings:
norm = raw.strip().lower().replace("-", "_")
if "zh_cn" in norm or "zh_hans" in norm or "china" in norm:
return True
for raw in tz_strings:
norm = raw.strip().lower()
if norm in _CN_TZ_NAMES or "china standard time" in norm:
return True
except Exception:
pass
return False
def candidates(cn_hint: Optional[bool] = None) -> list[str]:
"""The endpoint registry, probe-ordered by the locale/timezone hint."""
if cn_hint is None:
cn_hint = cn_probe_hint()
if cn_hint:
return [COMMUNITY_MIRROR, CANONICAL_ENDPOINT]
return [CANONICAL_ENDPOINT, COMMUNITY_MIRROR]
# ── Mode / explicit-setting resolution ──────────────────────────────────────
def env_opt_out() -> bool:
return (os.environ.get(MODE_ENV) or "").strip().lower() in _OPT_OUT_VALUES
def explicit_endpoint() -> str:
"""The endpoint the user explicitly configured, or "".
Same resolution the download paths use: ``HF_ENDPOINT`` env (what
Settings Models persists via user_env and what main.py loads at boot)
with the ``hf_endpoint`` pref as fallback. Unlike
``core.failure.configured_hf_mirror`` this does NOT filter the official
endpoint explicitly choosing huggingface.co is still an explicit
choice. Never raises."""
ep = (os.environ.get("HF_ENDPOINT") or "").strip().rstrip("/")
if ep:
return ep
try:
from core import prefs
return str(prefs.get("hf_endpoint", "") or "").strip().rstrip("/")
except Exception:
return ""
def mode() -> str:
"""``"auto"`` or ``"manual"``. Manual whenever the user opted out via
env, has an explicit endpoint anywhere, or picked a manual mode in
Settings (including explicitly choosing the official endpoint)."""
if env_opt_out():
return "manual"
if explicit_endpoint():
return "manual"
try:
from core import prefs
if str(prefs.get(_MODE_PREF, "") or "").strip().lower() == "manual":
return "manual"
except Exception:
pass
return "auto"
def set_mode_pref(value: str) -> None:
"""Persist the Settings-panel mode choice ("auto" | "manual")."""
from core import prefs
prefs.set_(_MODE_PREF, value)
# ── Decision cache (prefs conventions) ──────────────────────────────────────
def cached_decision() -> Optional[dict]:
"""The persisted race decision, or None. Shape-validated; never raises."""
try:
from core import prefs
d = prefs.get(_DECISION_PREF)
except Exception:
return None
if (
isinstance(d, dict)
and isinstance(d.get("endpoint"), str)
and d.get("endpoint")
and isinstance(d.get("checked_at"), (int, float))
):
return d
return None
def _store_decision(decision: dict) -> None:
try:
from core import prefs
prefs.set_(_DECISION_PREF, decision)
except Exception: # a broken prefs file must never break downloads
logger.warning("could not persist endpoint decision", exc_info=True)
def decision_is_fresh(decision: Optional[dict], now: Optional[float] = None) -> bool:
if not decision:
return False
now = time.time() if now is None else now
age = now - float(decision.get("checked_at") or 0)
return 0 <= age <= DECISION_MAX_AGE_S
# ── The race ────────────────────────────────────────────────────────────────
def race(
endpoints: Optional[list[str]] = None,
prober: Optional[Callable[[str], ProbeResult]] = None,
throughput_prober: Optional[Callable[[str], Optional[float]]] = None,
now: Optional[float] = None,
) -> dict:
"""Probe all candidates in parallel and decide. Pure given the probers.
Policy: reachable beats unreachable; with both reachable the canonical
endpoint wins unless the mirror is ``MIRROR_SPEEDUP_FACTOR``× faster on
latency AND the ranged-GET throughput sample doesn't contradict it (a
failed/unavailable throughput probe leaves the latency verdict standing).
Neither reachable canonical, ``reachable=False`` (nothing works anyway;
the offline copy owns messaging).
Returns ``{"endpoint", "reachable", "latency_ms", "checked_at",
"results": [...]}``.
"""
cands = endpoints if endpoints is not None else candidates()
# Resolve module attrs at call time so tests can patch probe_endpoint /
# throughput_probe and every caller (preflight, Settings) picks it up.
do_probe = prober if prober is not None else probe_endpoint
do_throughput = throughput_prober if throughput_prober is not None else throughput_probe
with ThreadPoolExecutor(max_workers=max(1, len(cands))) as pool:
results = list(pool.map(do_probe, cands))
by_endpoint = {r.endpoint: r for r in results}
canonical = by_endpoint.get(CANONICAL_ENDPOINT)
reachable = [r for r in results if r.reachable and r.latency_ms is not None]
reachable.sort(key=lambda r: r.latency_ms)
if not reachable:
winner = ProbeResult(endpoint=CANONICAL_ENDPOINT, reachable=False,
error=(canonical.error if canonical else "unreachable"))
elif canonical is None or not canonical.reachable:
winner = reachable[0]
else:
winner = canonical
fastest_mirror = next((r for r in reachable if r.endpoint != CANONICAL_ENDPOINT), None)
if (
fastest_mirror is not None
and canonical.latency_ms is not None
and fastest_mirror.latency_ms * MIRROR_SPEEDUP_FACTOR <= canonical.latency_ms
):
# Decisive latency win — confirm with throughput (what a real
# multi-GB download feels) before moving the user off canonical.
tp_mirror = do_throughput(fastest_mirror.endpoint)
tp_canonical = do_throughput(CANONICAL_ENDPOINT)
if tp_mirror is not None and tp_canonical is not None and tp_mirror < tp_canonical:
winner = canonical # latency was noise; canonical still wins
else:
winner = fastest_mirror
decision = {
"endpoint": winner.endpoint,
"reachable": winner.reachable,
"latency_ms": winner.latency_ms,
"checked_at": time.time() if now is None else now,
"results": [asdict(r) for r in results],
}
logger.info(
"HF endpoint race: picked %s (reachable=%s, latency=%sms) from %s",
winner.endpoint, winner.reachable, winner.latency_ms,
[(r.endpoint, r.reachable, r.latency_ms) for r in results],
)
return decision
def ensure_decision(
force: bool = False,
prober: Optional[Callable[[str], ProbeResult]] = None,
throughput_prober: Optional[Callable[[str], Optional[float]]] = None,
) -> Optional[dict]:
"""The current auto decision, racing only when needed. None in manual mode.
Races when: no cached decision (first run), the cache is stale
(>``DECISION_MAX_AGE_S``), or ``force=True`` (preflight, "Test again",
download-failure failover). Otherwise the cached decision is returned
untouched launches stay probe-free."""
if mode() != "auto":
return None
with _race_lock:
d = cached_decision()
if not force and decision_is_fresh(d):
return d
d = race(prober=prober, throughput_prober=throughput_prober)
_store_decision(d)
return d
def effective_endpoint() -> Optional[str]:
"""The endpoint downloads should pass as ``endpoint=``, or None (canonical).
Explicit user configuration always wins; in auto mode this returns the
cached decision's mirror when one was picked. NEVER probes — safe on the
per-download hot path. Never raises."""
try:
ep = explicit_endpoint()
if ep:
return ep
if mode() != "auto":
return None
d = cached_decision()
if d and d.get("reachable") and d["endpoint"] != CANONICAL_ENDPOINT:
return d["endpoint"]
except Exception:
logger.warning("effective_endpoint failed; using canonical", exc_info=True)
return None
def reselect_after_failure(repo_id: str, reason: Optional[str] = None) -> bool:
"""After a network-classified download failure: re-race once and report
whether the effective endpoint changed (the caller then retries on it).
Guarded once per repo per process (mirrors the cache-recovery ladder's
retry-once guard) so a network that stays broken can't loop probe↔retry.
No-op in manual mode and for non-network failures. Never raises."""
try:
if mode() != "auto":
return False
if reason is not None:
from core.failure import is_hf_connectivity_error
if not is_hf_connectivity_error(reason):
return False
if repo_id in _FAILOVER_ATTEMPTED:
return False
_FAILOVER_ATTEMPTED.add(repo_id)
before = effective_endpoint()
ensure_decision(force=True)
after = effective_endpoint()
if after != before:
logger.warning(
"HF endpoint failover for %s: %s%s (download failed with a "
"network error; retrying on the new endpoint)",
repo_id, before or CANONICAL_ENDPOINT, after or CANONICAL_ENDPOINT,
)
return True
return False
except Exception:
logger.warning("endpoint failover for %s errored", repo_id, exc_info=True)
return False
+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()
+30 -3
View File
@@ -786,7 +786,13 @@ def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
_last_repair_error = f"{type(imp_err).__name__}: {imp_err}"
return False
dl_kwargs: dict = {"repo_id": checkpoint}
endpoint = os.environ.get("HF_ENDPOINT")
# Explicit endpoint (HF_ENDPOINT / pref) wins; otherwise the automatic
# endpoint selection's cached pick applies (services.endpoint_race).
try:
from services import endpoint_race
endpoint = endpoint_race.effective_endpoint()
except Exception: # endpoint resolution must never break the repair
endpoint = os.environ.get("HF_ENDPOINT")
if endpoint:
dl_kwargs["endpoint"] = endpoint
if force:
@@ -837,8 +843,29 @@ def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
checkpoint, attempt, retries, e,
)
_last_repair_error = f"{type(e).__name__}: {e}"
if attempt < retries and backoff:
time.sleep(backoff * attempt)
if attempt < retries:
# Endpoint failover (auto mode only, once per repo per
# process — same guard pattern as the snapshot-link rung): a
# network-classified repair failure re-races the endpoints so
# the next attempt retries on the winner instead of burning
# every retry on a dead host. Explicit user endpoints are
# never switched.
try:
from services import endpoint_race
if endpoint_race.reselect_after_failure(checkpoint, str(e)):
new_ep = endpoint_race.effective_endpoint()
if new_ep:
dl_kwargs["endpoint"] = new_ep
else:
dl_kwargs.pop("endpoint", None)
logger.info(
"Auto-repair of %s: endpoint failover — retrying on %s",
checkpoint, new_ep or "https://huggingface.co",
)
except Exception: # failover must never break the ladder
pass
if backoff:
time.sleep(backoff * attempt)
return False
+921
View File
@@ -0,0 +1,921 @@
"""One-click sidecar-engine provisioner (issue: IndexTTS-2 in-app install).
Some engines (IndexTTS-2 today; MOSS-v1.5 / dots.tts / Confucius4 are the
same shape) can't live in the app venv because they pin a ``transformers``
version that conflicts with the parent's ``>=5.3``. They run as sidecars:
a source checkout + a dedicated venv + (for IndexTTS-2) model weights in
``<checkout>/checkpoints/``. Until now provisioning that trio was four
manual terminal steps; this module turns it into a resumable background
job the Settings Engines UI can start and poll.
Design notes (single source of truth for the choices):
* **Fetch: git primary, tarball fallback.** ``git clone --depth 1`` is the
primary path (fast, matches the documented manual flow, and leaves a
repo the user can update). When git is absent common on Windows we
fall back to downloading the GitHub source tarball over HTTPS (httpx,
honours proxy env vars) and extracting it with :mod:`tarfile`. A
``pip install git+https://`` path was rejected because the engine
*directory* must exist on disk anyway: the sidecar resolves its venv
and model weights relative to it.
* **Managed install root:** ``DATA_DIR/engines/<engine_id>/`` always
user-writable (works in frozen/packaged builds where ``backend/`` is
read-only), survives app updates, and never collides with a user's own
clone. A user-managed install (env var already pointing at their clone)
is left completely alone.
* **Weights ARE part of the install** for engines whose sidecar loads
from ``<checkout>/<weights_subdir>/`` (IndexTTS-2's ``main.py`` reads
``$OMNIVOICE_INDEXTTS_DIR/checkpoints/config.yaml`` verified). The
download goes through ``huggingface_hub.snapshot_download`` with the
endpoint from :mod:`services.endpoint_race` (HF endpoint auto-select;
**no hardcoded huggingface.co**) and the token from
:mod:`services.token_resolver`.
* **Idempotent + resumable:** every step no-ops when its output is
already healthy and repairs it when it is half-there (a checkout
without ``pyproject.toml`` is re-fetched; a venv that can't import the
probe module is re-installed; ``snapshot_download`` resumes weights).
* **Persistence:** on success the checkout path is written to
``os.environ[<env_var>]`` (the engine's bootstrap reads the env var, so
it works immediately no restart) and to ``prefs.json`` under
``env.<env_var>`` (restored into the environment at startup by
``main.py``), the same mechanism Settings' env panel uses.
Cross-platform: no symlinks, no shell strings (argv lists only), venv
layout resolved per-OS (``Scripts/python.exe`` vs ``bin/python``).
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
from core.config import DATA_DIR
logger = logging.getLogger("omnivoice.sidecar_install")
_GIB = 1024 ** 3
# Headroom kept free on the target volume on top of the estimated install
# size. Same needs-X/have-Y message shape as the model-install guard
# (api.routers.setup.models.disk_space_error), but a deliberately SMALLER
# floor than its MIN_FREE_GB=10: required_bytes here is already a
# conservative over-estimate, so stacking the full model-cache headroom on
# top would block legitimate installs on ~15 GB-free machines.
MIN_FREE_GB = 5
# Bounded in-memory log per job (last N lines survive; enough for the UI's
# log tail and for the failure remediation to quote real output).
_LOG_MAX_LINES = 200
_GIT_CLONE_TIMEOUT_S = 600
_TARBALL_TIMEOUT_S = 600
_UV_VENV_TIMEOUT_S = 300
_UV_PIP_INSTALL_TIMEOUT_S = 3600
_IMPORT_PROBE_TIMEOUT_S = 120
# ── Spec ───────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SidecarSpec:
"""Everything the provisioner needs to install one sidecar engine.
Parametrized so future sidecar engines (MOSS-v1.5, dots.tts,
Confucius4) become one SPECS entry, not another installer.
"""
engine_id: str
display_name: str
repo_url: str # git clone URL (primary fetch path)
tarball_url: str # source tarball (fallback when git is absent)
checkout_dirname: str # directory name of the checkout under the managed root
env_var: str # env var the engine's bootstrap reads (install dir)
probe_module: str # python -c "import <probe_module>" proves the venv works
weights_repo_id: Optional[str] = None # HF repo downloaded into <checkout>/<weights_subdir>
weights_subdir: str = "checkpoints"
docs_path: str = "docs/engines" # where the manual-install fallback lives
required_bytes: int = 12 * _GIB # conservative source+venv+weights estimate for preflight
# Called after a successful install/uninstall so the engine's memoised
# venv resolution re-probes (import inside the lambda — never at module load).
invalidate: Callable[[], None] = field(default=lambda: None)
# Cheap "is a healthy install already present?" probe (file existence only).
installed_probe: Callable[[], bool] = field(default=lambda: False)
def _indextts_invalidate() -> None:
from engines.indextts import bootstrap
bootstrap.invalidate()
def _indextts_installed() -> bool:
from engines.indextts.bootstrap import is_indextts_installed
return is_indextts_installed()
SPECS: dict[str, SidecarSpec] = {
"indextts2": SidecarSpec(
engine_id="indextts2",
display_name="IndexTTS-2",
repo_url="https://github.com/index-tts/index-tts.git",
tarball_url="https://github.com/index-tts/index-tts/archive/refs/heads/main.tar.gz",
checkout_dirname="index-tts",
env_var="OMNIVOICE_INDEXTTS_DIR",
probe_module="indextts.infer_v2",
weights_repo_id="IndexTeam/IndexTTS-2",
weights_subdir="checkpoints",
docs_path="docs/engines/indextts.md",
# ~0.1 GB source + up to ~6 GB venv (torch + transformers<5) +
# ~6 GB weights. Deliberately conservative; the preflight subtracts
# whatever a partial install already put on disk.
required_bytes=12 * _GIB,
invalidate=_indextts_invalidate,
installed_probe=_indextts_installed,
),
}
def get_spec(engine_id: str) -> Optional[SidecarSpec]:
return SPECS.get(engine_id)
def persistent_env_vars() -> set[str]:
"""Env vars the provisioner persists — merged into the Settings env-var
allowlist (api.routers.system.PERSISTENT_KEYS) so users can inspect or
clear them from the same panel as every other persisted var."""
return {s.env_var for s in SPECS.values()}
# ── Paths ──────────────────────────────────────────────────────────────────
def managed_root(spec: SidecarSpec) -> Path:
"""Per-engine managed install root (checkout lives inside it)."""
return Path(DATA_DIR) / "engines" / spec.engine_id
def managed_checkout(spec: SidecarSpec) -> Path:
return managed_root(spec) / spec.checkout_dirname
def _venv_python(venv_dir: Path) -> Path:
"""Venv python path, per-OS layout (Windows: Scripts/, POSIX: bin/)."""
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _locate_uv() -> Optional[str]:
"""Find uv: bundled (Tauri-set OMNIVOICE_BUNDLED_UV) first, then PATH.
Same resolution order as engines.indextts.bootstrap._locate_uv the
canonical uv-resolution pattern for sidecar venvs.
"""
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
if bundled and Path(bundled).is_file():
return bundled
return shutil.which("uv")
# ── Disk preflight ─────────────────────────────────────────────────────────
def _dir_size_bytes(path: Path) -> int:
"""Best-effort recursive size (bytes already spent by a partial install)."""
total = 0
try:
for root, _dirs, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
continue
except OSError:
pass # unreadable dir — treat as zero bytes spent
return total
def disk_free_bytes(path: Path) -> int:
"""Free bytes on the volume backing *path* (nearest existing ancestor).
Never raises; 0 when the volume can't be probed."""
try:
p = path.resolve()
while not p.exists():
parent = p.parent
if parent == p:
break
p = parent
return int(shutil.disk_usage(str(p)).free)
except Exception:
return 0
def disk_space_error(spec: SidecarSpec) -> Optional[str]:
"""Actionable message when the estimated remaining install won't fit
(needs X + headroom Y, have Z same shape as the model-install guard);
``None`` when it fits or the volume can't be probed."""
root = managed_root(spec)
already = _dir_size_bytes(root)
remaining = max(0, spec.required_bytes - already)
free = disk_free_bytes(root)
if free <= 0:
return None # can't probe → never block on missing information
required = remaining + MIN_FREE_GB * _GIB
if free >= required:
return None
def _gb(n: int) -> str:
return f"{n / _GIB:.1f} GB"
return (
f"Not enough disk space to install {spec.display_name}: it needs about "
f"{_gb(remaining)} plus {MIN_FREE_GB} GB free headroom ({_gb(required)} total), "
f"but only {_gb(free)} is free at {root}. Free up space and retry."
)
# ── Job state ──────────────────────────────────────────────────────────────
STEP_IDS = (
"preflight",
"fetch_source",
"create_venv",
"install_deps",
"verify",
"fetch_weights",
"persist",
)
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()
# Guards each job's log deque: the worker thread appends while the status
# poll copies it, and list() over a deque raises RuntimeError if it mutates
# mid-iteration. One module-level lock is plenty — appends are tiny and at
# most one job runs per engine.
_log_lock = threading.Lock()
class _StepError(Exception):
"""Install-step failure carrying user-facing remediation text."""
def __init__(self, message: str, remediation: str):
super().__init__(message)
self.remediation = remediation
def _new_job(engine_id: str) -> dict:
return {
"engine_id": engine_id,
"state": "running",
"steps": [{"id": s, "state": "pending", "detail": None} for s in STEP_IDS],
"log": deque(maxlen=_LOG_MAX_LINES),
"error": None,
"remediation": None,
"weights_progress": None,
"started_at": time.time(),
"finished_at": None,
}
def _job_step(job: dict, step_id: str) -> dict:
return next(s for s in job["steps"] if s["id"] == step_id)
def _log(job: dict, line: str) -> None:
line = line.rstrip()
if line:
with _log_lock:
job["log"].append(line)
logger.info("[%s install] %s", job["engine_id"], line)
def _serialize_job(job: Optional[dict]) -> Optional[dict]:
if job is None:
return None
out = dict(job)
with _log_lock:
out["log"] = list(job["log"])
out["steps"] = [dict(s) for s in job["steps"]]
return out
def get_status(engine_id: str) -> dict:
"""Install state + last/current job for one engine. Cheap (file probes)."""
spec = get_spec(engine_id)
if spec is None:
raise KeyError(engine_id)
with _jobs_lock:
job = _serialize_job(_jobs.get(engine_id))
installed = _healthy(spec)
checkout = managed_checkout(spec)
env_dir = os.environ.get(spec.env_var)
return {
"engine_id": engine_id,
"installed": installed,
# True when the on-disk install is the app-managed one (uninstallable
# from the app). A user's own clone is never "managed".
"managed": bool(
checkout.is_dir()
and (not env_dir or Path(env_dir) == checkout)
),
"install_dir": env_dir or (str(checkout) if checkout.is_dir() else None),
"job": job,
}
def _safe_installed(spec: SidecarSpec) -> bool:
try:
return bool(spec.installed_probe())
except Exception:
return False
def _user_managed_dir(spec: SidecarSpec) -> Optional[Path]:
"""The user's own install dir when the env var points anywhere but the
app-managed checkout; None for managed/unset (ours to provision)."""
env_dir = os.environ.get(spec.env_var)
if env_dir and Path(env_dir) != managed_checkout(spec):
return Path(env_dir)
return None
def _healthy(spec: SidecarSpec) -> bool:
"""A COMPLETE install: for a user-managed dir, trust the engine's own
probe (their clone, their layout); for the app-managed install require
the venv AND the fully-downloaded weights, so a partial install repairs
instead of reporting already_installed."""
if _user_managed_dir(spec) is not None:
return _safe_installed(spec)
checkout = managed_checkout(spec)
if not checkout.is_dir():
# No managed install at all. A legacy install may still exist (e.g.
# IndexTTS's old lazy-bootstrap venv under backend/engines/) — trust
# the engine's own probe so we never re-provision over a working one.
return _safe_installed(spec)
if not _venv_python(checkout / ".venv").is_file():
return False
if spec.weights_repo_id and not _weights_present(spec):
return False
return True
def _persist(spec: SidecarSpec) -> None:
"""Point the engine at the managed checkout: process env for immediate
use, prefs.json ``env.*`` for the next launch, and invalidate the
engine's memoised venv resolution so it re-probes without a restart."""
checkout = managed_checkout(spec)
os.environ[spec.env_var] = str(checkout)
from core import prefs
prefs.set_(f"env.{spec.env_var}", str(checkout))
try:
spec.invalidate()
except Exception:
pass # best-effort cache invalidation — the env var is already set
def start_install(engine_id: str) -> dict:
"""Start (or report) the install job for *engine_id*.
Returns ``{"status": "started"|"already_running"|"already_installed", ...}``.
Raises KeyError for an engine with no sidecar spec.
"""
spec = get_spec(engine_id)
if spec is None:
raise KeyError(engine_id)
with _jobs_lock:
existing = _jobs.get(engine_id)
if existing and existing["state"] == "running":
return {"status": "already_running", "engine": engine_id}
# A healthy install (user-managed or app-managed) never reinstalls;
# a PARTIAL managed install falls through so the job repairs it.
if _healthy(spec):
# Self-heal: a healthy MANAGED install whose env var was lost
# (e.g. prefs.json wiped) just needs re-pointing, not a reinstall.
if (
_user_managed_dir(spec) is None
and _venv_python(managed_checkout(spec) / ".venv").is_file()
and not _safe_installed(spec)
):
_persist(spec)
return {"status": "already_installed", "engine": engine_id}
job = _new_job(engine_id)
_jobs[engine_id] = job
th = threading.Thread(
target=_run_install, args=(spec, job),
name=f"sidecar-install-{engine_id}", daemon=True,
)
th.start()
return {"status": "started", "engine": engine_id}
def uninstall(engine_id: str) -> dict:
"""Remove the app-managed install and clear the persisted path.
Refuses to touch a user-managed install (env var pointing anywhere but
the managed checkout) those were never ours to delete.
"""
spec = get_spec(engine_id)
if spec is None:
raise KeyError(engine_id)
with _jobs_lock:
job = _jobs.get(engine_id)
if job and job["state"] == "running":
return {"status": "install_in_progress", "engine": engine_id}
env_dir = os.environ.get(spec.env_var)
checkout = managed_checkout(spec)
if env_dir and Path(env_dir) != checkout:
return {
"status": "not_managed",
"engine": engine_id,
"detail": (
f"{spec.display_name} points at {env_dir}, which OmniVoice did not "
f"install. Remove that directory yourself if you want it gone, or "
f"clear {spec.env_var} in Settings."
),
}
root = managed_root(spec)
removed = root.is_dir()
shutil.rmtree(root, ignore_errors=True)
if env_dir: # only ever the managed checkout at this point
os.environ.pop(spec.env_var, None)
from core import prefs
if prefs.get(f"env.{spec.env_var}") == str(checkout):
prefs.delete(f"env.{spec.env_var}")
try:
spec.invalidate()
except Exception:
pass # best-effort cache invalidation — uninstall already succeeded
with _jobs_lock:
_jobs.pop(engine_id, None)
return {"status": "uninstalled" if removed else "not_installed", "engine": engine_id}
# ── Worker ─────────────────────────────────────────────────────────────────
def _run_install(spec: SidecarSpec, job: dict) -> None:
step_fns: list[tuple[str, Callable[[SidecarSpec, dict], None]]] = [
("preflight", _step_preflight),
("fetch_source", _step_fetch_source),
("create_venv", _step_create_venv),
("install_deps", _step_install_deps),
("verify", _step_verify),
("fetch_weights", _step_fetch_weights),
("persist", _step_persist),
]
try:
for step_id, fn in step_fns:
step = _job_step(job, step_id)
step["state"] = "running"
try:
fn(spec, job)
except _StepError:
step["state"] = "error"
raise
except Exception as exc: # noqa: BLE001 — surfaced into the job
step["state"] = "error"
raise _StepError(
f"{type(exc).__name__}: {exc}",
"Re-run the install — it resumes from where it stopped. If it "
f"keeps failing, see {spec.docs_path} for the manual steps.",
) from exc
if step["state"] == "running":
step["state"] = "done"
job["state"] = "succeeded"
_log(job, f"{spec.display_name} installed successfully.")
except _StepError as exc:
job["state"] = "failed"
job["error"] = str(exc)
job["remediation"] = exc.remediation
_log(job, f"FAILED: {exc}")
finally:
job["finished_at"] = time.time()
def _step_preflight(spec: SidecarSpec, job: dict) -> None:
if _locate_uv() is None:
raise _StepError(
"uv was not found (checked the bundled path via OMNIVOICE_BUNDLED_UV, "
"then PATH).",
"Install uv from https://docs.astral.sh/uv/ and relaunch OmniVoice, or "
"set OMNIVOICE_BUNDLED_UV to the absolute path of a uv binary.",
)
err = disk_space_error(spec)
if err:
raise _StepError(err, "Free up disk space (or move OmniVoice's data directory "
"to a larger volume) and retry.")
managed_root(spec).mkdir(parents=True, exist_ok=True)
_job_step(job, "preflight")["detail"] = "uv found, disk space OK"
_log(job, "Preflight OK — uv resolved and enough free disk space.")
def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "fetch_source")
checkout = managed_checkout(spec)
if (checkout / "pyproject.toml").is_file():
step["state"] = "done"
step["detail"] = "source already present"
_log(job, f"Source already present at {checkout} — skipping fetch.")
return
if checkout.exists():
# Half-fetched checkout (no pyproject.toml) — repair by refetching.
_log(job, f"Removing incomplete checkout at {checkout}")
shutil.rmtree(checkout, ignore_errors=True)
git = shutil.which("git")
if git:
_log(job, f"Cloning {spec.repo_url} (git, depth 1) …")
rc = _run_logged(job, [git, "clone", "--depth", "1", spec.repo_url, str(checkout)],
timeout=_GIT_CLONE_TIMEOUT_S)
if rc == 0 and (checkout / "pyproject.toml").is_file():
step["detail"] = "git clone"
return
_log(job, f"git clone failed (exit {rc}) — falling back to source tarball.")
shutil.rmtree(checkout, ignore_errors=True)
else:
_log(job, "git not found — using the source-tarball fallback.")
_fetch_tarball(spec, job, checkout)
if not (checkout / "pyproject.toml").is_file():
raise _StepError(
f"Fetched source at {checkout} has no pyproject.toml — the download "
"appears incomplete or the upstream layout changed.",
"Re-run the install; if it keeps failing, clone the repository "
f"manually and set {spec.env_var} to the clone (see the engine docs).",
)
step["detail"] = "source tarball"
def _fetch_tarball(spec: SidecarSpec, job: dict, checkout: Path) -> None:
"""Download + extract the GitHub source tarball (no git required).
Extraction is member-validated (no absolute paths / parent escapes) and
never uses symlinks, so it behaves identically on Windows.
"""
import httpx
root = managed_root(spec)
root.mkdir(parents=True, exist_ok=True)
_log(job, f"Downloading {spec.tarball_url}")
fd, tmp_tar = tempfile.mkstemp(suffix=".tar.gz", dir=str(root))
try:
with os.fdopen(fd, "wb") as out:
with httpx.stream(
"GET", spec.tarball_url, follow_redirects=True,
timeout=_TARBALL_TIMEOUT_S,
) as resp:
resp.raise_for_status()
for chunk in resp.iter_bytes():
out.write(chunk)
_log(job, "Extracting source tarball …")
with tempfile.TemporaryDirectory(dir=str(root)) as tmp_dir:
with tarfile.open(tmp_tar, "r:gz") as tf:
try:
tf.extractall(tmp_dir, filter="data") # stdlib safe-extract (3.11.4+)
except TypeError: # pragma: no cover — pre-filter= interpreters
_safe_extract_members(tf, tmp_dir)
entries = [p for p in Path(tmp_dir).iterdir() if p.is_dir()]
if len(entries) != 1:
raise _StepError(
f"Unexpected tarball layout ({len(entries)} top-level dirs).",
"Re-run the install; if it keeps failing, clone the repository "
f"manually and set {spec.env_var} (see the engine docs).",
)
# os.replace-style move keeps this atomic-ish on the same volume.
shutil.move(str(entries[0]), str(checkout))
finally:
try:
os.unlink(tmp_tar)
except OSError:
pass # temp tarball already gone / locked — harmless leftover
def _safe_extract_members(tf: "tarfile.TarFile", dest: str) -> None:
"""Tar-slip-guarded extraction for interpreters without
``extractall(filter="data")`` (Python < 3.11.4).
Mirrors what the "data" filter enforces: only regular files and
directories (no symlinks/hardlinks/devices also keeps Windows
behaviour identical), no absolute paths, and every resolved target must
stay inside *dest*.
"""
dest_abs = os.path.abspath(dest)
for member in tf.getmembers():
if not (member.isreg() or member.isdir()):
continue # drop symlinks/hardlinks/devices/fifos
name = member.name
if name.startswith(("/", "\\")) or ".." in name.replace("\\", "/").split("/"):
continue # absolute path or parent-dir escape
target = os.path.abspath(os.path.join(dest, name))
if os.path.commonpath([dest_abs, target]) != dest_abs:
continue # resolved outside the extraction dir
tf.extract(member, dest)
def _step_create_venv(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "create_venv")
checkout = managed_checkout(spec)
venv_dir = checkout / ".venv"
py = _venv_python(venv_dir)
if py.is_file():
step["state"] = "done"
step["detail"] = "venv already present"
_log(job, f"Venv already present at {venv_dir} — skipping.")
return
uv = _locate_uv()
_log(job, f"Creating venv at {venv_dir}")
rc = _run_logged(job, [uv, "venv", str(venv_dir)], timeout=_UV_VENV_TIMEOUT_S)
if rc != 0 or not py.is_file():
raise _StepError(
f"uv venv failed (exit {rc}) at {venv_dir}.",
"Check the log above for the uv error; free disk space or fix "
"permissions on the data directory, then re-run the install.",
)
step["detail"] = "venv created"
def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
"""`uv pip install -e <checkout>` into the dedicated venv.
Deliberately NOT `uv sync` sync would apply the sidecar's lockfile
semantics; `uv pip install -e` resolves the sidecar's own pins
(e.g. transformers<5) inside ITS venv, never touching the parent app.
Idempotent: re-running repairs a partial dependency set.
"""
checkout = managed_checkout(spec)
py = _venv_python(checkout / ".venv")
uv = _locate_uv()
_log(job, f"Installing {spec.display_name} into its venv (this can take several minutes) …")
rc = _run_logged(
job,
[uv, "pip", "install", "--python", str(py), "-e", str(checkout)],
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
)
if rc != 0:
raise _StepError(
f"uv pip install -e failed (exit {rc}).",
"Usually a network hiccup — re-run the install to resume. Behind a "
"proxy, set HTTPS_PROXY in Settings → Environment first.",
)
_job_step(job, "install_deps")["detail"] = "dependencies installed"
def _step_verify(spec: SidecarSpec, job: dict) -> None:
checkout = managed_checkout(spec)
py = _venv_python(checkout / ".venv")
_log(job, f"Verifying `import {spec.probe_module}` inside the venv …")
try:
proc = subprocess.run(
[str(py), "-c", f"import {spec.probe_module}"],
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
raise _StepError(
f"Import probe failed to run: {exc}",
"Re-run the install; if it keeps failing, delete the engine in "
"Settings → Engines and install again.",
) from exc
if proc.returncode != 0:
tail = proc.stderr.decode("utf-8", errors="replace")[-500:]
raise _StepError(
f"`import {spec.probe_module}` failed in the new venv: {tail}",
"Re-run the install — dependency resolution resumes and repairs "
"partial installs. If it keeps failing, use the manual install in "
"the engine docs.",
)
_job_step(job, "verify")["detail"] = f"import {spec.probe_module} OK"
_log(job, "Venv verified.")
# Written into the weights dir after snapshot_download COMPLETES. A partial
# multi-shard download can leave config.yaml + several plausible shards on
# disk, so file heuristics alone would declare a killed-mid-download install
# healthy and never resume it (the sidecar edition of #352). Only this
# installer writes the marker; user-managed clones never hit this path.
_WEIGHTS_COMPLETE_MARKER = ".omnivoice_weights_complete"
def _weights_present(spec: SidecarSpec) -> bool:
"""True only for a COMPLETED weights download: the completion marker
plus a sanity floor (config.yaml + one 5 MB weight file the same
truncated-download floor the model store uses)."""
wdir = managed_checkout(spec) / spec.weights_subdir
if not (wdir / _WEIGHTS_COMPLETE_MARKER).is_file():
return False
return _weights_floor_ok(wdir)
def _weights_floor_ok(wdir: Path) -> bool:
if not (wdir / "config.yaml").is_file():
return False
floor = 5 * 1024 * 1024
try:
for root, _dirs, files in os.walk(wdir):
for f in files:
try:
if os.path.getsize(os.path.join(root, f)) >= floor:
return True
except OSError:
continue
except OSError:
pass # unreadable weights dir — treat as not present
return False
def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "fetch_weights")
if not spec.weights_repo_id:
step["state"] = "skipped"
step["detail"] = "engine has no bundled-weights requirement"
return
if _weights_present(spec):
step["state"] = "done"
step["detail"] = "weights already present"
_log(job, "Model weights already present — skipping download.")
return
wdir = managed_checkout(spec) / spec.weights_subdir
wdir.mkdir(parents=True, exist_ok=True)
_log(job, f"Downloading {spec.weights_repo_id}{wdir} (several GB — resumable) …")
from huggingface_hub import snapshot_download
from services import endpoint_race
from services.token_resolver import resolve as resolve_token
from utils import hf_progress
# Mirror per-file byte progress into the job so the polling UI can show
# it — same tqdm hook the model store's SSE feed uses.
def _listener(ev: dict) -> None:
try:
# Only mirror events for OUR repo — a concurrent model-store
# download must not scribble its progress into this job.
if ev.get("repo_id") not in (None, spec.weights_repo_id):
return
job["weights_progress"] = {
"filename": ev.get("filename"),
"downloaded": ev.get("downloaded"),
"total": ev.get("total"),
"pct": ev.get("pct"),
}
except Exception:
pass # progress mirroring is advisory — never break the download
listener_id = hf_progress.register_listener(_listener)
repo_token = hf_progress.current_repo_id.set(spec.weights_repo_id)
try:
# Tracks the repo's default branch on purpose (same policy as every
# other model download in the app — see setup/download.py): the
# source checkout is unpinned upstream `main` anyway, and hf_hub
# checksum-verifies each artifact. Hence the B615 waiver below.
kwargs: dict = {
"repo_id": spec.weights_repo_id,
"local_dir": str(wdir),
"token": resolve_token(),
}
endpoint = endpoint_race.effective_endpoint()
if endpoint:
kwargs["endpoint"] = endpoint
tqdm_cls = hf_progress.tracked_tqdm_class()
if tqdm_cls is not None:
kwargs["tqdm_class"] = tqdm_cls
try:
snapshot_download(**kwargs) # nosec B615 — deliberate default-branch policy, see above
except Exception as exc:
raise _StepError(
f"Model weight download failed: {exc}",
"Re-run the install — the download resumes where it stopped. "
"Check Settings → Network (HF endpoint / proxy) if it keeps failing.",
) from exc
finally:
hf_progress.unregister_listener(listener_id)
hf_progress.current_repo_id.reset(repo_token)
if not _weights_floor_ok(wdir):
raise _StepError(
"Weight download finished but no plausible weight files were found — "
"the download was likely interrupted.",
"Re-run the install to resume the download.",
)
# snapshot_download returned AND the sanity floor holds → mark complete,
# so _weights_present/_healthy stop treating this dir as a partial.
(wdir / _WEIGHTS_COMPLETE_MARKER).write_text(
f"{spec.weights_repo_id}\n{time.time():.0f}\n", encoding="utf-8",
)
step["detail"] = "weights downloaded"
_log(job, "Model weights downloaded.")
def _step_persist(spec: SidecarSpec, job: dict) -> None:
_persist(spec)
_job_step(job, "persist")["detail"] = f"{spec.env_var}={managed_checkout(spec)}"
_log(job, f"Saved {spec.env_var} — the engine is ready to use, no restart needed.")
# ── Subprocess runner with live log capture ────────────────────────────────
def _run_logged(job: dict, argv: list[str], *, timeout: float) -> int:
"""Run *argv*, streaming combined stdout+stderr lines into the job log.
Returns the exit code; -1 on timeout (process tree killed) or spawn
failure. argv-list only never a shell string so paths with spaces
are safe on every platform.
The stdout drain runs on its own daemon thread and the main flow blocks
on ``proc.wait(timeout=)``. That bounds the step even when a grandchild
(uv resolver worker, git helper) inherits the pipe and outlives the
killed child a blocking ``for line in proc.stdout`` on this thread
would hang past the timeout waiting for pipe EOF.
"""
popen_kwargs: dict = {}
if os.name == "posix":
# New session → we can kill the whole process group on timeout
# instead of only the direct child.
popen_kwargs["start_new_session"] = True
try:
proc = subprocess.Popen(
argv,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
**popen_kwargs,
)
except OSError as exc:
_log(job, f"failed to spawn {argv[0]}: {exc}")
return -1
def _drain() -> None:
try:
assert proc.stdout is not None
for line in proc.stdout:
_log(job, line)
except (OSError, ValueError):
pass # pipe closed by the timeout kill — nothing left to read
drain = threading.Thread(target=_drain, daemon=True)
drain.start()
try:
rc = proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
_kill_tree(proc)
_log(job, f"process timed out after {timeout:.0f}s — killed")
return -1
drain.join(5.0) # give the drain a moment to flush the tail
return rc if rc is not None else -1
def _kill_tree(proc: "subprocess.Popen") -> None:
"""Kill the child and its whole process tree, on every platform.
POSIX: the child was started in its own session, so SIGKILL the group.
Windows: ``proc.kill()`` only terminates the direct child a git/uv
helper it spawned would keep running (and writing into the checkout)
past our timeout so use ``taskkill /T`` to fell the tree.
"""
if os.name == "posix":
import signal
try:
os.killpg(proc.pid, signal.SIGKILL)
return
except (ProcessLookupError, PermissionError, OSError):
pass # group already gone / not ours — fall through to plain kill
else: # Windows
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True, timeout=15,
)
return
except (OSError, subprocess.SubprocessError):
pass # taskkill unavailable/failed — fall through to plain kill
try:
proc.kill()
except OSError:
pass # process already exited
__all__ = [
"SPECS",
"SidecarSpec",
"disk_space_error",
"get_spec",
"get_status",
"managed_checkout",
"managed_root",
"persistent_env_vars",
"start_install",
"uninstall",
]
+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()
+59
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.
@@ -1677,6 +1698,23 @@ _MLX_AUDIO_MODEL_LABELS: dict[str, str] = {
}
def _sidecar_installable_ids() -> frozenset[str]:
"""Engine ids with a one-click sidecar installer. Deferred import — the
installer module is tiny, but keeping the import inside the function
means a broken/absent installer can never take the engine picker down.
All current sidecar SPECS are TTS engines, so only this registry carries
``one_click_install``; the first non-TTS sidecar engine will need the same
field plumbed into asr_backend/llm_backend.list_backends and the Install
button into their matrix rows.
"""
try:
from services.sidecar_install import SPECS
return frozenset(SPECS)
except Exception: # pragma: no cover — defensive only
return frozenset()
def list_backends() -> list[dict]:
"""Enumerate every registered backend with its availability state.
@@ -1687,11 +1725,17 @@ 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
"one_click_install": bool, # services.sidecar_install can provision it in-app
"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
@@ -1719,6 +1763,7 @@ def list_backends() -> list[dict]:
from core.device_caps import detect_host_caps
from services.engine_routing import routing_fields
caps = detect_host_caps()
installable = _sidecar_installable_ids()
out: list[dict] = []
for bid, cls in _REGISTRY.items():
@@ -1746,14 +1791,28 @@ 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),
# True when services.sidecar_install can provision this engine
# in-app (Settings renders an Install button instead of leading
# with the manual setup snippet).
"one_click_install": bid in installable,
"last_error": _LAST_ERRORS.get(bid),
"isolation_mode": isolation,
"gpu_compat": list(gpu_compat),
+41
View File
@@ -0,0 +1,41 @@
"""Shared setup for backend/tests — import path + hermetic data dir.
Historically every module in this directory stubbed
``sys.modules["core.config"]`` with a bare 3-4 attribute ``ModuleType``
pointing at its own ``mkdtemp``. That stub leaked **process-wide at
collection time**: pytest imports test modules while collecting, so in any
mixed invocation (``pytest tests/... backend/tests/...``) every *later* lazy
import of ``core.config`` resolved the stub instead of the real module
``tests/test_router_smoke.py``'s ``from main import app`` died with
ImportError (missing config attrs), and
``monkeypatch.setattr("core.config.X", ...)`` died with AttributeError
(``core`` never gets a ``config`` attribute when the name is satisfied
straight from ``sys.modules``). That was the root cause of the
order-pollution combos around test_longform_e2e (8 AttributeErrors) and
test_router_smoke (24 fixture ImportErrors).
The real ``core.config`` derives every path from ``OMNIVOICE_DATA_DIR`` at
import time, so pointing that env var at a throwaway dir *before* any test
module imports it gives the same hermeticity (issue #878: never touch the
developer's real app state) with zero ``sys.modules`` surgery. This mirrors
``tests/conftest.py``; in a mixed run whichever conftest loads first wins
(``setdefault`` semantics) and both point at a throwaway tmpdir.
Do NOT reintroduce module-level ``sys.modules`` stubs in this directory
import the real module and rely on this conftest instead.
"""
import os
import sys
import tempfile
# Backend runs with `--app-dir backend`, so tests must do the same.
_BACKEND = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _BACKEND not in sys.path:
sys.path.insert(0, _BACKEND)
if not os.environ.get("OMNIVOICE_DATA_DIR"):
os.environ["OMNIVOICE_DATA_DIR"] = tempfile.mkdtemp(prefix="omnivoice-test-data-")
if not os.environ.get("OMNIVOICE_ENV_FILE"):
os.environ["OMNIVOICE_ENV_FILE"] = os.path.join(
os.environ["OMNIVOICE_DATA_DIR"], "user-env"
)
@@ -14,24 +14,13 @@ verified manually (spectral flatness back in the speech range + Whisper ASR).
from __future__ import annotations
import math
import os
import sys
import tempfile
import types
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Stub core.config before the router imports OUTPUTS_DIR / VOICES_DIR from it.
_TMP = tempfile.mkdtemp(prefix="omnivoice_preview_q_")
_config = types.ModuleType("core.config")
_config.DATA_DIR = _TMP
_config.VOICES_DIR = str(Path(_TMP) / "voices")
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
sys.modules["core.config"] = _config
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before the router imports OUTPUTS_DIR / VOICES_DIR from
# the REAL core.config (the old sys.modules stub leaked at collection time
# and broke later importers in mixed runs — see conftest.py).
torch = pytest.importorskip("torch") # noqa: E402
from api.routers import archetypes as arch # noqa: E402
+3 -19
View File
@@ -9,29 +9,13 @@ generation.py's proven ``_run_inference`` rather than re-implementing it.
"""
from __future__ import annotations
import os
import sys
import tempfile
import types
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Stub core.config before the router imports VOICES_DIR / OUTPUTS_DIR from it.
_TMP = tempfile.mkdtemp(prefix="omnivoice_arch_test_")
_VOICES = Path(_TMP) / "voices"
_OUTPUTS = Path(_TMP) / "outputs"
_VOICES.mkdir(parents=True, exist_ok=True)
_OUTPUTS.mkdir(parents=True, exist_ok=True)
_config = types.ModuleType("core.config")
_config.DATA_DIR = _TMP
_config.VOICES_DIR = str(_VOICES)
_config.OUTPUTS_DIR = str(_OUTPUTS)
sys.modules["core.config"] = _config
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before the router imports VOICES_DIR / OUTPUTS_DIR from
# the REAL core.config (the old sys.modules stub leaked at collection time).
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
+3 -10
View File
@@ -8,21 +8,14 @@ OOM deterministically (no GPU needed) and asserts the device switch.
"""
from __future__ import annotations
import os
import sys
import tempfile
import types
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
_config = types.ModuleType("core.config")
_config.DATA_DIR = tempfile.mkdtemp(prefix="omnivoice_asr_oom_")
_config.VOICES_DIR = _config.DATA_DIR
_config.OUTPUTS_DIR = _config.DATA_DIR
sys.modules["core.config"] = _config
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before this module imports the REAL core.config (the old
# sys.modules stub leaked at collection time and broke mixed runs).
whisperx = pytest.importorskip("whisperx")
from services.asr_backend import ( # noqa: E402
+5 -19
View File
@@ -3,32 +3,18 @@
is covered by the manifest round-trip (tests/test_longform_resume.py) + the
existing render tests, not here.
Config-stub pattern (mounts only the audiobook router torch-free at import) so
it runs locally without the main+torch segfault.
Mounts only the audiobook router (no ``main`` import) so it runs locally
without the main+torch segfault; conftest.py provides the hermetic data dir.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import types
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
_TMP = tempfile.mkdtemp(prefix="omnivoice_abresume_test_")
_config = types.ModuleType("core.config")
_config.DATA_DIR = _TMP
_config.VOICES_DIR = str(Path(_TMP) / "voices")
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
_config.DB_PATH = str(Path(_TMP) / "omnivoice.db")
os.makedirs(_config.VOICES_DIR, exist_ok=True)
os.makedirs(_config.OUTPUTS_DIR, exist_ok=True)
sys.modules["core.config"] = _config
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before this module imports the REAL core.config (the old
# sys.modules stub leaked at collection time and broke mixed runs).
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
+3 -11
View File
@@ -6,19 +6,11 @@ lightweight — it only imports os, uuid, time, asyncio, logging,
fastapi, and pydantic at module level.
"""
import io
import os
import sys
import pytest
# Add backend to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Stub core.config before batch imports it
import types
config_mod = types.ModuleType("core.config")
config_mod.DATA_DIR = "/tmp/omnivoice_test_data"
sys.modules["core.config"] = config_mod
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before the batch router imports the REAL core.config (the
# old sys.modules stub leaked at collection time and broke mixed runs).
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routers.batch import router, _jobs, _set_progress
+5 -11
View File
@@ -4,17 +4,11 @@ Only tests the pure-Python helper functions (no GPU needed).
The WebSocket endpoint itself requires the full app, which we
skip in CI it's integration-tested via the browser.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Stub heavy deps
import types
for mod_name in ["services.model_manager", "services.asr_backend", "services.ffmpeg_utils"]:
if mod_name not in sys.modules:
sys.modules[mod_name] = types.ModuleType(mod_name)
# conftest.py puts `backend/` on sys.path. capture_ws imports its heavy deps
# (model_manager / asr_backend / ffmpeg_utils) lazily inside handlers, so no
# stubbing is needed — the old empty-ModuleType stubs leaked process-wide at
# collection time and broke every later `from services.ffmpeg_utils import
# find_ffmpeg` in mixed runs (see conftest.py).
from api.routers.capture_ws import _chunks_to_wav, MIN_BUFFER_BYTES
+3 -14
View File
@@ -9,23 +9,12 @@ are exercised at runtime.
from __future__ import annotations
import json
import os
import sys
import tempfile
import types
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
_TMP = tempfile.mkdtemp(prefix="omnivoice_community_test_")
_config = types.ModuleType("core.config")
_config.DATA_DIR = _TMP
_config.VOICES_DIR = str(Path(_TMP) / "voices")
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
sys.modules["core.config"] = _config
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before this module imports the REAL core.config (the old
# sys.modules stub leaked at collection time and broke mixed runs).
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
+44
View File
@@ -0,0 +1,44 @@
"""Regression guard for the collection-time sys.modules stub class.
Seven modules in this directory used to install bare ``types.ModuleType``
stubs for ``core.config`` (and test_capture_ws.py for ``services.*``) at
module level. pytest imports every test module during *collection*, so the
stubs leaked process-wide before a single test ran: in any mixed invocation
(``pytest tests/... backend/tests/...``) later lazy imports resolved the
stub tests/test_router_smoke.py's ``from main import app`` died with
``ImportError: cannot import name 'find_ffmpeg' from 'services.ffmpeg_utils'
(unknown location)`` and tests/test_longform_e2e.py's
``monkeypatch.setattr("core.config.OUTPUTS_DIR", ...)`` died with
``AttributeError: module 'core' has no attribute 'config'``.
This test runs after collection has imported every sibling module, so any
reintroduced module-level stub trips it even in a backend/tests-only run.
A stub is recognizable because a bare ModuleType has neither ``__file__``
(real module) nor ``__path__`` (namespace package).
If you need a fake module in a test, use ``monkeypatch.setitem(sys.modules,
name, fake)`` inside the test pytest restores it. For hermetic data dirs,
rely on conftest.py's ``OMNIVOICE_DATA_DIR`` redirect instead of stubbing
``core.config``.
"""
import sys
# Backend packages whose identity later tests depend on. Top-level third-party
# modules are out of scope (some legitimately lack __file__, e.g. frozen ones).
_GUARDED_PREFIXES = ("core.", "api.", "services.", "schemas.", "utils.")
def test_no_collection_time_sys_modules_stubs():
offenders = []
for name, mod in list(sys.modules.items()):
if mod is None or not name.startswith(_GUARDED_PREFIXES):
continue
if getattr(mod, "__file__", None) is None and not hasattr(mod, "__path__"):
offenders.append(name)
assert not offenders, (
"Stub module(s) found in sys.modules: "
f"{offenders}. Some test module installed a bare ModuleType at import "
"time; that leaks process-wide from pytest collection and breaks "
"every later import of the real module in mixed runs. Use "
"monkeypatch.setitem(sys.modules, ...) inside the test instead."
)
+7 -18
View File
@@ -6,34 +6,23 @@ the model. The export path's preview generation needs torchaudio and is covered
by the service-layer round-trip in ``tests/test_persona_bundle.py`` + CI; here we
only assert export's 404 (which fails before any audio work).
Follows the config-stub pattern of ``test_archetypes_api.py`` / ``test_community.py``
so it mounts ONLY the persona router on a bare FastAPI app (no ``main`` import,
no torch at collection).
Follows the pattern of ``test_archetypes_api.py`` / ``test_community.py``:
mounts ONLY the persona router on a bare FastAPI app (no ``main`` import,
no torch at collection); conftest.py provides the hermetic data dir.
"""
from __future__ import annotations
import io
import json
import os
import sys
import tempfile
import types
import zipfile
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
_TMP = tempfile.mkdtemp(prefix="omnivoice_personas_test_")
_VOICES = Path(_TMP) / "voices"
_VOICES.mkdir(parents=True, exist_ok=True)
_config = types.ModuleType("core.config")
_config.DATA_DIR = _TMP
_config.VOICES_DIR = str(_VOICES)
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
_config.DB_PATH = str(Path(_TMP) / "omnivoice.db")
sys.modules["core.config"] = _config
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before this module imports the REAL core.config (the old
# sys.modules stub leaked at collection time and broke mixed runs).
from core import config as _config # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
+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"]
+22 -7
View File
@@ -76,18 +76,33 @@ default** (set its var to `0` to disable); the rest default **off**.
## Restricted networks / mirrors (e.g. China)
If `huggingface.co` is slow or blocked, point the client at a mirror:
**Automatic (the default).** When no endpoint is explicitly configured,
OmniVoice picks one for you: it probes `huggingface.co` and the community
mirror `hf-mirror.com` in parallel (short HTTPS reachability + latency
checks — no geo-IP lookups, no third-party services; your device
language/timezone only decides which endpoint is probed *first*), prefers the
official endpoint unless the mirror is decisively faster, and remembers the
winner. The decision is re-tested only on the first-run system check, after a
network-classified download failure (the failed download retries once on the
new winner), when it's more than 7 days old, or when you press **Test again**
in **Settings → Models → Hugging Face mirror**. Mirror integrity is a
non-issue: `huggingface_hub` verifies every download by checksum regardless
of endpoint. Opt out with `OMNIVOICE_HF_ENDPOINT_MODE=manual`.
**Explicit (always wins).** To pin an endpoint yourself:
```
HF_ENDPOINT=https://hf-mirror.com
```
Set it in **Settings → Models → Hugging Face mirror** (quick-pick presets
included), or as an environment variable before launching. On first run, the
setup wizard offers the same mirror quick-pick right on the system-check
screen when the endpoint is unreachable — the network check is a warning, not
a blocker, so an offline or firewalled machine can still finish setup once
models are available (mirror, or manual download below). Caveats:
Set it in **Settings → Models → Hugging Face mirror** (quick-pick presets and
a custom URL — any explicit choice switches the panel to manual mode and is
**never** auto-switched), or as an environment variable before launching. On
first run, the setup wizard's network check reports which endpoint the
automatic selection picked, and still offers the mirror quick-pick when
nothing is reachable — the check is a warning, not a blocker, so an offline
or firewalled machine can still finish setup once models are available
(mirror, or manual download below). Caveats:
- A mirror serves the **classic** download path, **not Xet** — you lose
chunk-dedup and Xet's parallel fetch, but you gain reachability. On the
+72 -18
View File
@@ -8,12 +8,57 @@ pins `transformers>=5.3`. This isolation is the resolution of
canonical `OffloadedCache` ImportError that resulted from loading
both libraries inside one Python interpreter.
## Install
## Install (one-click, recommended)
IndexTTS-2 is **not** bundled with OmniVoice — the model weights are
~6 GB and the package itself pins a conflicting transformers
version. OmniVoice ships with a sidecar runner that loads IndexTTS
into an isolated venv on demand.
into an isolated venv on demand, plus a guided installer that
provisions everything for you:
1. Open **Settings → Engines**, expand the IndexTTS2 row
("Why unavailable?"), and click **Install**.
2. Watch the step-by-step progress: preflight (uv + disk space),
source fetch, isolated venv creation, dependency install,
verification, model-weight download (~6 GB, resumable), and
configuration save.
3. Done — the engine flips to `available: true` immediately, **no
restart needed**.
What the installer does under the hood (all cross-platform —
macOS / Windows / Linux):
* Fetches the IndexTTS source with `git clone --depth 1` (or, when
git isn't installed, downloads the GitHub source tarball over
HTTPS) into OmniVoice's data directory
(`<data-dir>/engines/indextts2/index-tts`).
* Creates a dedicated venv inside the checkout with `uv venv` and
runs `uv pip install -e .` against it — the `transformers<5`
isolation is preserved; the parent app's environment is never
touched. uv is resolved from `OMNIVOICE_BUNDLED_UV`, then `PATH`.
* Downloads the `IndexTeam/IndexTTS-2` weights into
`checkpoints/` (where the sidecar loads them from), honouring your
configured/auto-selected Hugging Face endpoint and HF token.
* Persists `OMNIVOICE_INDEXTTS_DIR` for you (in-process for
immediate use + `prefs.json` for the next launch).
Preflight requires roughly **12 GB free disk space** (source + venv +
weights, checked before anything is written); the install fails early
with an actionable message otherwise. Re-running the installer is
always safe: it repairs partial installs and resumes interrupted
downloads instead of starting over. An app-managed install can be
removed again with `DELETE /engines/sidecar/indextts2/install` (a
user-managed clone is never touched).
If you already installed IndexTTS manually (any OmniVoice version),
the installer detects it via `OMNIVOICE_INDEXTTS_DIR` and reports
`already_installed` — nothing is re-downloaded or moved.
## Manual install (fallback)
The manual flow still works and is what the installer automates. Use
it if you want the clone somewhere specific, share one clone across
tools, or can't use the in-app installer:
1. Clone the IndexTTS repo on disk:
@@ -32,16 +77,12 @@ into an isolated venv on demand.
uv pip install -e .
```
3. Download the model weights (~6 GB). Either:
3. Download the model weights (~6 GB):
```bash
hf download IndexTeam/IndexTTS-2 --local-dir=checkpoints
```
or let HuggingFace cache them on first synthesize call (the parent
forwards `HF_HOME` / `HF_HUB_CACHE` to the sidecar so the cache is
shared with the rest of OmniVoice's downloads).
4. Set the `OMNIVOICE_INDEXTTS_DIR` environment variable to the repo
root (the directory that contains `checkpoints/` and
`pyproject.toml`):
@@ -65,12 +106,14 @@ into an isolated venv on demand.
OmniVoice probes for a usable IndexTTS Python interpreter in this
priority order (see `backend/engines/indextts/bootstrap.py`):
1. **`${OMNIVOICE_INDEXTTS_DIR}/.venv/`** — your existing clone's
venv. Highest priority, so v0.2.7 users who already ran
`uv pip install -e .` get zero migration cost on the upgrade to
v0.3.x.
1. **`${OMNIVOICE_INDEXTTS_DIR}/.venv/`** — the install dir's own
venv. This is what BOTH the one-click installer (which sets
`OMNIVOICE_INDEXTTS_DIR` to its managed checkout) and a manual
clone resolve to. Highest priority, so v0.2.7 users who already
ran `uv pip install -e .` get zero migration cost on the upgrade
to v0.3.x.
2. **`backend/engines/indextts/.venv/`** — OmniVoice's own venv,
created on demand by step 3.
created on demand by the lazy bootstrap below.
3. **Lazy bootstrap** — if neither venv exists, OmniVoice runs
`uv venv backend/engines/indextts/.venv` and
`uv pip install --python <python> -e ${OMNIVOICE_INDEXTTS_DIR}`
@@ -87,14 +130,25 @@ weights survive the upgrade byte-for-byte.
### `IndexTTS-2 venv not found. Set OMNIVOICE_INDEXTTS_DIR ...`
You haven't pointed OmniVoice at an IndexTTS clone yet. Follow the
**Install** steps above.
You haven't installed IndexTTS yet. Click **Install** on the
IndexTTS2 row in **Settings → Engines** (recommended), or follow the
**Manual install** steps above.
### `uv is required to bootstrap the IndexTTS-2 venv but was not found on PATH`
### `uv was not found` / `uv is required to bootstrap the IndexTTS-2 venv but was not found on PATH`
The bootstrap path needs a working `uv` binary. Either install `uv`
into your `PATH` (https://docs.astral.sh/uv/) or pre-create the venv
manually with `uv venv` and `uv pip install -e` as in step 2.
Both the one-click installer and the bootstrap path need a working
`uv` binary (resolved from `OMNIVOICE_BUNDLED_UV`, then `PATH`).
Either install `uv` into your `PATH` (https://docs.astral.sh/uv/) or
pre-create the venv manually with `uv venv` and `uv pip install -e`
as in the manual steps.
### `Not enough disk space to install IndexTTS-2 ...`
The installer's preflight found less free space than the estimated
source + venv + weights footprint (plus headroom). The message names
the exact numbers; free up space (or move OmniVoice's data directory
to a larger volume) and click Install again — it resumes where it
stopped.
### `IndexTTS bootstrap completed but `import indextts.infer_v2` still fails`
+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
+36 -4
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
@@ -323,10 +349,16 @@ order:
files are a common false-positive quarantine), then re-enable.
- **Connection** — use a stable, direct connection; pause any VPN; avoid
corporate/school networks.
- **Region mirror** — if `huggingface.co` is slow/blocked where you are, pick a
mirror in-app (**Settings → Models → Hugging Face mirror**, or the quick-pick
the first-run system check offers when the endpoint is unreachable), or set
it as an env var before launching and relaunch:
- **Region mirror** — if `huggingface.co` is slow/blocked where you are,
OmniVoice normally handles this automatically: with no endpoint explicitly
configured it probes both the official endpoint and the `hf-mirror.com`
community mirror and downloads from whichever works (downloads are
checksum-verified either way; see
[downloading-models.md](../downloading-models.md)). To check or re-test the
automatic pick, use **Settings → Models → Hugging Face mirror → Test
again**. To pin a mirror yourself, pick one in the same panel (or the
quick-pick the first-run system check offers when nothing is reachable), or
set it as an env var before launching and relaunch:
- macOS/Linux: `export HF_ENDPOINT=https://hf-mirror.com`
- Windows (PowerShell): `[Environment]::SetEnvironmentVariable("HF_ENDPOINT","https://hf-mirror.com","User")`
+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.18",
"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.18"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.3.16"
version = "0.3.18"
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 —
+58
View File
@@ -84,6 +84,64 @@ export async function selfTestEngine(engineId: string): Promise<EngineSelfTestRe
return apiPost<EngineSelfTestResponse>(`/engines/${encodeURIComponent(engineId)}/selftest`, {});
}
// ── One-click sidecar-engine install (IndexTTS-2 & friends) ─────────────
export type SidecarStepState = 'pending' | 'running' | 'done' | 'skipped' | 'error';
export interface SidecarInstallStep {
id: string;
state: SidecarStepState;
detail: string | null;
}
export interface SidecarInstallJob {
engine_id: string;
state: 'running' | 'succeeded' | 'failed';
steps: SidecarInstallStep[];
log: string[];
error: string | null;
remediation: string | null;
weights_progress: {
filename: string | null;
downloaded: number | null;
total: number | null;
pct: number | null;
} | null;
started_at: number;
finished_at: number | null;
}
export interface SidecarInstallStatus {
engine_id: string;
installed: boolean;
managed: boolean;
install_dir: string | null;
job: SidecarInstallJob | null;
}
export interface SidecarInstallStartResponse {
status: 'started' | 'already_running' | 'already_installed';
engine: string;
}
/** Start the resumable one-click install for a sidecar engine (IndexTTS-2).
* Idempotent: re-POSTing while a job runs returns `already_running`; a
* healthy install returns `already_installed`; a partial install repairs. */
export async function installSidecarEngine(engineId: string): Promise<SidecarInstallStartResponse> {
return apiPost<SidecarInstallStartResponse>(
`/engines/sidecar/${encodeURIComponent(engineId)}/install`,
{},
);
}
/** Poll the sidecar install job step-by-step states + log tail + error
* with remediation. Cheap (file probes only), safe to poll every ~1.5 s. */
export async function getSidecarInstallStatus(engineId: string): Promise<SidecarInstallStatus> {
return apiJson<SidecarInstallStatus>(
`/engines/sidecar/${encodeURIComponent(engineId)}/install/status`,
);
}
export async function listTranslationEngines(): Promise<TranslationEnginesResponse> {
return apiJson<TranslationEnginesResponse>('/engines/translation');
}
+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> {
+12
View File
@@ -29,10 +29,22 @@ 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.
setup_snippet?: string | null;
// True when the backend's sidecar provisioner can install this engine
// in-app (Settings renders an Install button; the manual snippet is
// demoted to a collapsible fallback). Absent on legacy payloads.
one_click_install?: boolean;
last_error?: string | null;
isolation_mode?: 'in-process' | 'subprocess';
gpu_compat?: GPUTarget[];
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>
);
}
@@ -13,9 +13,19 @@ vi.mock('../../api/engines', () => ({
selectEngine: vi.fn(),
getEngineHealth: vi.fn(),
selfTestEngine: vi.fn(),
installSidecarEngine: vi.fn(),
getSidecarInstallStatus: 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 +53,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 +117,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' }),
},
]}
/>
}
@@ -1,27 +1,51 @@
/**
* Settings Models tab Hugging Face mirror panel (Wave 4.3).
*
* Restricted-network users (e.g. behind the Great Firewall) point
* huggingface_hub at a mirror via HF_ENDPOINT. HF reads it at import time, so
* the change applies after a restart. Persisted to the durable per-user env.
* Default mode is **Auto (recommended)**: the backend probes the official
* endpoint and the community mirror, picks whichever actually works
* (preferring huggingface.co unless the mirror is decisively faster), and
* remembers the pick restricted-network users (e.g. behind the Great
* Firewall) get working downloads without hunting for this panel. Downloads
* are checksum-verified by Hugging Face regardless of endpoint.
*
* Explicit choices stay explicit: picking a preset or saving a custom URL
* pins that endpoint (HF_ENDPOINT, persisted to the durable per-user env; HF
* reads it at import time, so loads apply after a restart) and auto never
* switches it. Existing configured endpoints load as the matching manual
* mode never migrated to Auto.
*
* Endpoints (loopback-only):
* GET /api/settings/hf-mirror {configured, effective, presets}
* PUT /api/settings/hf-mirror body {url} (empty url clears official)
* GET /api/settings/hf-mirror {configured, effective, presets, mode, auto}
* PUT /api/settings/hf-mirror body {url, mode} (mode 'auto' clears the url)
* POST /api/settings/hf-mirror/test re-run the probe race ("Test again")
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Globe } from 'lucide-react';
import { Globe, RefreshCw, Zap } 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(/\/+$/, '');
/** Host of the auto pick ("hf-mirror.com"), for compact display. */
const hostOf = (url) => {
try {
return new URL(url).hostname || url;
} catch {
return url || '';
}
};
export default function HFMirrorPanel() {
const { t } = useTranslation();
const [state, setState] = useState(null);
const [url, setUrl] = useState('');
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState(null);
const [restart, setRestart] = useState(false);
@@ -40,19 +64,20 @@ export default function HFMirrorPanel() {
refresh();
}, [refresh]);
const save = async (value) => {
const save = async (value, mode = 'manual') => {
setSaving(true);
setError(null);
try {
const res = await apiFetch('/api/settings/hf-mirror', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: value }),
body: JSON.stringify({ url: value, mode }),
});
const d = await res.json();
setState(d);
setUrl(d.configured || '');
setRestart(Boolean(d.restart_required));
refresh();
toast.success(t('models.mirror_saved', { defaultValue: 'Mirror setting saved' }));
} catch (e) {
setError(e?.message || t('models.mirror_save_error'));
} finally {
@@ -60,8 +85,63 @@ export default function HFMirrorPanel() {
}
};
if (!state) return null;
const testAgain = async () => {
setTesting(true);
setError(null);
try {
const res = await apiFetch('/api/settings/hf-mirror/test', { method: 'POST' });
const d = await res.json();
setState(d);
setUrl(d.configured || '');
} catch (e) {
setError(
e?.message || t('models.mirror_auto_test_error', { defaultValue: 'Endpoint test failed' }),
);
} finally {
setTesting(false);
}
};
const configured = normalizeMirror(state?.configured);
const isAuto = state?.mode === 'auto';
const auto = state?.auto || null;
// Auto status line: pick + latency + last checked, or the untested/offline states.
let autoStatus = null;
if (isAuto) {
if (!auto) {
autoStatus = t('models.mirror_auto_untested', {
defaultValue: 'Not tested yet — the next download picks the best endpoint automatically.',
});
} else if (!auto.reachable) {
autoStatus = t('models.mirror_auto_offline', {
defaultValue:
'No Hugging Face endpoint reachable right now — cached models keep working; auto retries on the next download.',
});
} else {
const parts = [hostOf(auto.endpoint)];
if (typeof auto.latency_ms === 'number') {
parts.push(
t('models.mirror_auto_latency', {
ms: Math.round(auto.latency_ms),
defaultValue: '{{ms}} ms',
}),
);
}
if (auto.checked_at) {
parts.push(
t('models.mirror_auto_checked', {
when: new Date(auto.checked_at * 1000).toLocaleString(),
defaultValue: 'checked {{when}}',
}),
);
}
autoStatus = parts.join(' · ');
}
}
// 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 +155,128 @@ 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"
{!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">
<Button
variant="preset"
active={isAuto}
onClick={() => save('', 'auto')}
disabled={saving}
leading={<Zap size={12} aria-hidden="true" />}
data-testid="hf-preset-auto"
>
{t('models.mirror_mode_auto', { defaultValue: 'Auto (recommended)' })}
</Button>
{state.presets.map((p) => (
<Button
variant="preset"
key={p.label}
active={!isAuto && normalizeMirror(p.url) === configured}
onClick={() => save(p.url, 'manual')}
disabled={saving}
data-testid={`hf-preset-${p.url || 'official'}`}
>
{p.label}
</Button>
))}
</div>
}
/>
{isAuto && (
<SettingRow
stack
title={t('models.mirror_auto_title', { defaultValue: 'Automatic selection' })}
note={t('models.mirror_auto_hint', {
defaultValue:
'OmniVoice probes huggingface.co and the community mirror, then uses whichever actually works — preferring the official endpoint unless the mirror is decisively faster. Downloads are checksum-verified by Hugging Face regardless of endpoint, so a mirror can never corrupt models.',
})}
control={
<>
<span
className="min-w-0 truncate font-mono text-[length:var(--text-sm)] text-[color:var(--chrome-fg-muted)]"
data-testid="hf-mirror-auto-status"
>
{autoStatus}
</span>
<Button
variant="subtle"
size="sm"
leading={<RefreshCw size={13} aria-hidden="true" />}
onClick={testAgain}
loading={testing}
disabled={testing || saving}
data-testid="hf-mirror-test"
>
{testing
? t('models.mirror_auto_testing', { defaultValue: 'Testing…' })
: t('models.mirror_auto_test', { defaultValue: 'Test again' })}
</Button>
</>
}
/>
<Button
variant="subtle"
size="sm"
onClick={() => save(url)}
loading={saving}
disabled={saving}
data-testid="hf-mirror-save"
>
{t('common.save')}
</Button>
</>
}
/>
)}
<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, 'manual')}
loading={saving}
disabled={saving}
data-testid="hf-mirror-save"
>
{t('common.save')}
</Button>
</>
}
/>
</>
)}
</SettingsSection>
);
}
@@ -0,0 +1,199 @@
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 PRESETS = [
{ label: 'Official (huggingface.co)', url: '' },
{ label: 'hf-mirror.com (community, China)', url: 'https://hf-mirror.com' },
];
// An existing explicit mirror config loads as the matching MANUAL mode.
const MANUAL_STATE = {
configured: 'https://hf-mirror.com',
effective: 'https://hf-mirror.com',
presets: PRESETS,
mode: 'manual',
auto: null,
auto_opt_out: false,
};
const AUTO_STATE = {
configured: '',
effective: '',
presets: PRESETS,
mode: 'auto',
auto: {
endpoint: 'https://hf-mirror.com',
reachable: true,
latency_ms: 87.3,
checked_at: 1752200000,
results: [],
},
auto_opt_out: false,
};
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(MANUAL_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('loads an existing explicit config as the matching manual mode — never Auto', async () => {
apiJson.mockResolvedValue(MANUAL_STATE);
render(<HFMirrorPanel />);
const mirror = await screen.findByTestId('hf-preset-https://hf-mirror.com');
const official = screen.getByTestId('hf-preset-official');
const auto = screen.getByTestId('hf-preset-auto');
expect(mirror).toHaveAttribute('aria-pressed', 'true');
expect(official).toHaveAttribute('aria-pressed', 'false');
expect(auto).toHaveAttribute('aria-pressed', 'false');
// Manual mode has no auto-status row.
expect(screen.queryByTestId('hf-mirror-auto-status')).not.toBeInTheDocument();
});
it('labels the custom-URL row in plain language and toasts on save', async () => {
apiJson.mockResolvedValue(MANUAL_STATE);
apiFetch.mockResolvedValue({
json: async () => ({
...MANUAL_STATE,
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',
body: JSON.stringify({ url: 'https://mirror.example', mode: 'manual' }),
}),
);
});
it('shows the Auto pick with measured latency, last-checked, and a Test again button', async () => {
apiJson.mockResolvedValue(AUTO_STATE);
render(<HFMirrorPanel />);
const auto = await screen.findByTestId('hf-preset-auto');
expect(auto).toHaveAttribute('aria-pressed', 'true');
const status = screen.getByTestId('hf-mirror-auto-status');
expect(status).toHaveTextContent('hf-mirror.com');
expect(status).toHaveTextContent('87 ms');
expect(status).toHaveTextContent('checked');
expect(screen.getByTestId('hf-mirror-test')).toBeInTheDocument();
});
it('shows an honest untested state in Auto mode before the first race', async () => {
apiJson.mockResolvedValue({ ...AUTO_STATE, auto: null });
render(<HFMirrorPanel />);
const status = await screen.findByTestId('hf-mirror-auto-status');
expect(status).toHaveTextContent('Not tested yet');
});
it('Test again POSTs to the test endpoint and updates the shown pick', async () => {
apiJson.mockResolvedValue(AUTO_STATE);
apiFetch.mockResolvedValue({
json: async () => ({
...AUTO_STATE,
auto: { ...AUTO_STATE.auto, endpoint: 'https://huggingface.co', latency_ms: 42 },
}),
});
render(<HFMirrorPanel />);
fireEvent.click(await screen.findByTestId('hf-mirror-test'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/api/settings/hf-mirror/test', { method: 'POST' }),
);
await waitFor(() =>
expect(screen.getByTestId('hf-mirror-auto-status')).toHaveTextContent('huggingface.co'),
);
});
it('clicking Auto saves mode=auto; clicking a preset saves an explicit manual pick', async () => {
apiJson.mockResolvedValue(MANUAL_STATE);
apiFetch.mockResolvedValue({
json: async () => ({ ...AUTO_STATE, restart_required: false }),
});
render(<HFMirrorPanel />);
fireEvent.click(await screen.findByTestId('hf-preset-auto'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
'/api/settings/hf-mirror',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ url: '', mode: 'auto' }),
}),
),
);
// Panel reflects the returned Auto state (pick row appears).
expect(await screen.findByTestId('hf-mirror-auto-status')).toBeInTheDocument();
apiFetch.mockResolvedValue({
json: async () => ({ ...MANUAL_STATE, restart_required: true }),
});
fireEvent.click(screen.getByTestId('hf-preset-https://hf-mirror.com'));
await waitFor(() =>
expect(apiFetch).toHaveBeenLastCalledWith(
'/api/settings/hf-mirror',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ url: 'https://hf-mirror.com', mode: 'manual' }),
}),
),
);
});
});
@@ -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' })}>

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