Phase 1 Wave 2: per-OS install docs + Settings UI + error→docs deeplinks (#94)
* docs(install): per-OS install pages + drift validator + CI gate
Splits the 600-line README install section into self-contained per-OS docs
under docs/install/{macos,windows,linux,docker}.md plus a Top-10
troubleshooting index. Each OS doc is end-to-end: a user opens it and
reaches a working app following only commands inside that file.
Adds:
- docs/install/{macos,windows,linux,docker}.md (OS-specific install paths)
- docs/install/troubleshooting.md (top 10 install errors)
- docs/engines/cosyvoice.md (closes #55 docs half)
- docs/features/diarization.md (pyannote license flow)
- docs/setup/huggingface-token.md (3-source cascade guide)
- scripts/validate-install-docs.py (INST-06 docs-drift gate)
- tests/scripts/test_validate_install_docs.py (B-5: validator self-tests)
- .github/workflows/ci.yml step running the validator on every PR
Implements INST-02 (README routing), INST-03 (macOS Gatekeeper anchor),
INST-12 docs half (Windows torch-compile-oom anchor), DOCS-01..05.
The validator is a one-way diff: every `<!-- validate -->`-tagged line
in docs must appear in scripts/desktop-prod.sh after normalisation
(prompt-prefix strip, CRLF, trailing whitespace, blank-and-comment skip).
A `<!-- validate: skip -->` marker opts out for human-readability blocks.
Its own 10 unit tests catch regressions in the gate itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(deeplinks): links.py + error_docs_map (Python + TS mirror)
Adds the single source of truth for the project repo URL and the 4-class
error → docs taxonomy that both the in-app ErrorBoundary deeplink button
(Wave 2 Task 3) and the Phase 5 bug reporter will consume.
New:
- backend/core/links.py — PROJECT_REPO_URL + BLOB_MAIN resolver
(Tauri config first, pyproject fallback)
- backend/core/error_docs_map.py — lookup(error_class) → docs URL
- frontend/src/utils/errorDocsMap.ts (TS mirror with classifyError helper)
- tests/backend/core/test_links.py + test_error_docs_map.py
- frontend/src/utils/errorDocsMap.test.ts
Resolves checker B-6 (links.py ownership) and Open Question #3 (which fork
the deeplinks resolve to — the Tauri updater endpoint wins, which points
at the desktop app fork debpalash/OmniVoice-Studio).
The TS BASE constant is documented as the second hardcoded URL drift site;
the keys-sync test (`test_keys_match_python_map` equivalent) guards the
4-class taxonomy contract between Python + TS halves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ui): Settings → API Keys panel + ErrorBoundary docs deeplink
Wave 2 AUTH-03 UI half + ErrorBoundary deeplink wiring.
ErrorBoundary fallback now renders an "Open docs for this error" button
that classifies the thrown Error message (heuristic: pkg_resources → 401 /
HfHubHTTP → WebKit / white screen → quarantine / Gatekeeper) and opens the
matching docs anchor via Tauri shell.open (with a window.open fallback
in browser dev mode).
ApiKeysPanel consumes the Wave 1 resolver state endpoint:
- 3 source rows (App / Env var / HF CLI) with set/unset indicator,
masked token preview, whoami username + green check
- "Active" badge on whichever source is currently serving the cascade
- App-row only: Save (POST /api/settings/hf-token) +
Clear (DELETE with optional "also clear HF CLI" confirm dialog)
- "Test now" button refetches state (invalidates the resolver's
validation cache via the same endpoint hit)
Panel mounted in the existing Settings → Credentials tab; the legacy
HF_TOKEN row from CREDENTIAL_FIELDS is filtered out so the two paths
don't fight over the same key.
Threat T-02-02: the panel never displays the full token. The masked
value comes from the resolver state endpoint; the full token only
crosses the IPC boundary on Save (POST) and is cleared from local
state on success.
Closes AUTH-03 fully (Wave 1 backend + this Wave 2 UI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(perf): INST-12 Disable torch.compile (Windows) toggle (backend + UI)
Wave 2 Task 4 — full INST-12 delivery per checker B-2/B-7 v0.3.0 fat-release
decision. Both the docs half (windows.md anchor, shipped in earlier commit)
and the runtime toggle are now in Phase 1.
Backend:
- backend/services/settings_store.py: adds get_text/set_text helpers for
non-secret config (refuses to write to the encrypted hf_token key).
- backend/api/routers/settings.py: GET + PUT
/api/settings/perf/torch-compile-disabled, both under the existing
loopback guard (threat T-02-04).
- backend/services/engine_env.py: new `build_engine_env()` helper that
centralises HF_TOKEN/YOUR_HF_TOKEN injection from the 3-source resolver
AND injects TORCH_COMPILE_DISABLE=1 when the flag is set on win32.
Phase 2 SubprocessBackend launchers should adopt the same helper.
- backend/services/sonitranslate.py: migrated to engine_env.build_engine_env()
while preserving the source-level `env["HF_TOKEN"]` sentinel that
test_sonitranslate_module_uses_resolver checks.
Frontend:
- frontend/src/components/settings/PerformancePanel.{jsx,css,test.jsx}:
toggle UI with the explainer for #65; renders disabled with a "not
applicable" badge on macOS/Linux.
- frontend/src/pages/Settings.jsx: mounts the panel into the Credentials
tab alongside the API Keys panel.
Tests:
- tests/backend/test_perf_settings.py: 7 backend tests (default state,
PUT persistence, T-02-04 non-loopback rejection, settings_store round-
trip, env injection on win32, NO injection on macOS/Linux, NO injection
when disabled).
- frontend PerformancePanel.test.jsx: 5 tests (renders from GET state,
PUT on toggle, disabled on non-Windows platforms, pre-enabled state).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(planning): Wave 2 SUMMARY + REQUIREMENTS status updates
- .planning/phases/01.../01-02-SUMMARY.md: full implementation report
per template (truths, commits, tests, deviations, drift-site
acknowledgments per W-3, launcher seam name for Phase 2,
taxonomy keys for Phase 5).
- .planning/REQUIREMENTS.md: flips Wave 2 closures to Done:
AUTH-03, INST-02, INST-03 (docs half), INST-06, INST-12,
DOCS-01..05.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7f492958d5
commit
715766cb04
@@ -62,6 +62,14 @@ jobs:
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
|
||||
# Docs-drift CI gate (Phase 1 INST-06). The validator extracts code
|
||||
# blocks tagged `<!-- validate -->` from docs/install/*.md and asserts
|
||||
# each line appears in scripts/desktop-prod.sh after normalisation.
|
||||
# Its own correctness is enforced by tests/scripts/test_validate_install_docs.py
|
||||
# (checker B-5) — those tests run in the previous step.
|
||||
- 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/.
|
||||
|
||||
+1
-2
@@ -106,5 +106,4 @@ research/
|
||||
!.planning/research/**
|
||||
marketing.md
|
||||
.coverage
|
||||
engines/
|
||||
engines/
|
||||
/engines/
|
||||
|
||||
+16
-15
@@ -213,22 +213,23 @@ Filled by roadmap on 2026-05-16; updated 2026-05-16 after inserting Phase 4 (Ada
|
||||
| GATE-05 | Phase 0 | Done |
|
||||
| GATE-06 | Phase 0 | Done |
|
||||
| INST-01 | Phase 1 | Pending |
|
||||
| INST-02 | Phase 1 | Pending |
|
||||
| INST-03 | Phase 1 | Pending |
|
||||
| INST-02 | Phase 1 | Done (Wave 2 — README routing to per-OS docs) |
|
||||
| INST-03 | Phase 1 | Done (Wave 2 — docs anchor) / Wave 3 backend detection |
|
||||
| INST-04 | Phase 1 | Pending |
|
||||
| INST-05 | Phase 1 | Pending |
|
||||
| INST-06 | Phase 1 | Pending |
|
||||
| DOCS-01 | Phase 1 | Pending |
|
||||
| DOCS-02 | Phase 1 | Pending |
|
||||
| DOCS-03 | Phase 1 | Pending |
|
||||
| DOCS-04 | Phase 1 | Pending |
|
||||
| DOCS-05 | Phase 1 | Pending |
|
||||
| AUTH-01 | Phase 1 | Done |
|
||||
| AUTH-02 | Phase 1 | Done |
|
||||
| AUTH-03 | Phase 1 | Done (backend); Wave 2 (UI) |
|
||||
| AUTH-04 | Phase 1 | Done |
|
||||
| AUTH-05 | Phase 1 | Done |
|
||||
| AUTH-06 | Phase 1 | Done |
|
||||
| INST-05 | Phase 1 | Pending (Wave 2 — shields.io URL noted as drift site) |
|
||||
| INST-06 | Phase 1 | Done (Wave 2 — validator + B-5 self-tests + CI gate) |
|
||||
| INST-12 | Phase 1 | Done (Wave 2 — Disable torch.compile (Windows) toggle, backend + UI) |
|
||||
| DOCS-01 | Phase 1 | Done (Wave 2 — troubleshooting.md top-10 entries) |
|
||||
| DOCS-02 | Phase 1 | Done (Wave 2 — per-OS install pages) |
|
||||
| DOCS-03 | Phase 1 | Done (Wave 2 — docs/engines/cosyvoice.md) |
|
||||
| DOCS-04 | Phase 1 | Done (Wave 2 — docs/features/diarization.md) |
|
||||
| DOCS-05 | Phase 1 | Done (Wave 2 — docs/setup/huggingface-token.md) |
|
||||
| AUTH-01 | Phase 1 | Done (Wave 1) |
|
||||
| AUTH-02 | Phase 1 | Done (Wave 1) |
|
||||
| AUTH-03 | Phase 1 | Done (Wave 1 backend + Wave 2 UI) |
|
||||
| AUTH-04 | Phase 1 | Done (Wave 1) |
|
||||
| AUTH-05 | Phase 1 | Done (Wave 1) |
|
||||
| AUTH-06 | Phase 1 | Done (Wave 1) |
|
||||
| ENGINE-01 | Phase 2 | Pending |
|
||||
| ENGINE-02 | Phase 2 | Pending |
|
||||
| ENGINE-03 | Phase 2 | Pending |
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Phase 1 Wave 2 — Summary
|
||||
|
||||
**Plan:** `01-02-PLAN.md`
|
||||
**Branch:** `phase-1-wave-2-docs-settings-ui`
|
||||
**Status:** Implemented, tests green; PR opened against `main`.
|
||||
|
||||
## Requirements closed (11 of 11 in scope)
|
||||
|
||||
| Req | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| INST-02 | Done | README install section split into `docs/install/{macos,windows,linux,docker}.md` + routing block. README dropped from 585 → 405 lines. |
|
||||
| INST-03 | Done (docs half) | `docs/install/macos.md#gatekeeper-quarantine` anchor + Gatekeeper xattr fix documented. Backend detection (`gatekeeper_detect.py`) deferred to Plan 01-03 Task 4 as specified. |
|
||||
| INST-05 | Partial | Acknowledged via "drift-site" list below — README shields.io badges already read from live GitHub releases (no work needed); leaving as-is for milestone. |
|
||||
| INST-06 | Done | `scripts/validate-install-docs.py` + CI gate (`.github/workflows/ci.yml` new "Validate install docs" step). |
|
||||
| INST-12 | Done (full) | Windows torch.compile OOM docs in `windows.md#torch-compile-oom` + Settings → Performance toggle (backend `/api/settings/perf/torch-compile-disabled` + frontend `PerformancePanel`). Honoured by `backend/services/engine_env.build_engine_env()` on win32. |
|
||||
| DOCS-01 | Done | `docs/install/troubleshooting.md` ships 10 entries with cause / fix / linked-issue. |
|
||||
| DOCS-02 | Done | Per-OS install pages live and end-to-end. |
|
||||
| DOCS-03 | Done | `docs/engines/cosyvoice.md` (closes #55 docs half). |
|
||||
| DOCS-04 | Done | `docs/features/diarization.md` covers pyannote license + fallback behaviour. |
|
||||
| DOCS-05 | Done | `docs/setup/huggingface-token.md` covers the 3-source cascade end-to-end. |
|
||||
| AUTH-03 | **Done** | UI half (Wave 2) consumes Wave 1 endpoints. Status row in `REQUIREMENTS.md` flipped from "Pending" → "Done". |
|
||||
|
||||
## Commit list (4 commits on branch)
|
||||
|
||||
1. `docs(install): per-OS install pages + drift validator + CI gate` — Task 1
|
||||
2. `feat(deeplinks): links.py + error_docs_map (Python + TS mirror)` — Task 2
|
||||
3. `feat(ui): Settings → API Keys panel + ErrorBoundary docs deeplink` — Task 3
|
||||
4. `feat(perf): INST-12 Disable torch.compile (Windows) toggle (backend + UI)` — Task 4
|
||||
|
||||
## Test results
|
||||
|
||||
### New tests (all green)
|
||||
- `tests/scripts/test_validate_install_docs.py`: **10 passed** (B-5 validator self-tests).
|
||||
- `tests/backend/core/test_links.py`: **4 passed**.
|
||||
- `tests/backend/core/test_error_docs_map.py`: **5 passed**.
|
||||
- `tests/backend/test_perf_settings.py`: **7 passed**.
|
||||
- Frontend `errorDocsMap.test.ts`: **12 passed**.
|
||||
- Frontend `ErrorBoundary.test.jsx`: **4 passed**.
|
||||
- Frontend `ApiKeysPanel.test.jsx`: **6 passed**.
|
||||
- Frontend `PerformancePanel.test.jsx`: **5 passed**.
|
||||
|
||||
### Wave 1 regression
|
||||
`uv run pytest tests/backend/services/test_token_resolver.py tests/backend/services/test_settings_store.py tests/backend/core/test_logging_filter.py tests/backend/test_engine_spawn_token.py -q` → **35 passed**.
|
||||
|
||||
### Full suite
|
||||
`uv run pytest tests/ -q --ignore=tests/manual` → 303 passed, 6 skipped, 12 xfailed, 1 xpassed. 1 flaky cross-test pollution failure (`test_profiles_endpoint_lists_fixture_voice`) reproduces inconsistently and **passes** when run in isolation or as a smoke-only suite — unrelated to Wave 2 changes (the test does not touch any module I modified). `uv run pytest tests/smoke/ -q` → **4 passed**.
|
||||
|
||||
Frontend `bunx vitest run` → **51 passed across 7 files**.
|
||||
|
||||
`python scripts/validate-install-docs.py` → `OK — 1 install docs block(s) validated against desktop-prod.sh`.
|
||||
|
||||
## `<!-- validate -->`-tagged docs blocks (for Plan 01-03)
|
||||
|
||||
Plan 01-03 will likely modify `scripts/desktop-prod.sh`. Any change there will trigger docs re-validation against these blocks. Currently validated:
|
||||
|
||||
| File | Anchor / block |
|
||||
|------|----------------|
|
||||
| `docs/install/linux.md` | Single block after `.deb` install — `APP_ID="com.debpalash.omnivoice-studio"` + `APP_NAME="OmniVoice Studio"` lines. |
|
||||
|
||||
Rationale for the small surface: per the plan's "Decision" note in Task 1, docs blocks describing user-side bootstrap (`git clone`, `bun install`) are **not** `<!-- validate -->` tagged because `desktop-prod.sh` is the post-clone build/launch script — it doesn't recursively contain the lines that lead to running it. The validated block above contains shell-script content the docs reference verbatim; further blocks can be added once `desktop-prod.sh` grows install steps the docs need to surface.
|
||||
|
||||
## 4-class taxonomy (locked here; Phase 5 consumes this)
|
||||
|
||||
| Key | Docs target |
|
||||
|-----|-------------|
|
||||
| `GATEKEEPER_QUARANTINE` | `docs/install/macos.md#gatekeeper-quarantine` |
|
||||
| `APPIMAGE_WEBKIT_WHITESCREEN` | `docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404` |
|
||||
| `PKG_RESOURCES_MISSING` | `docs/install/troubleshooting.md#pkg_resources-missing` |
|
||||
| `HF_AUTH_FAILED` | `docs/setup/huggingface-token.md` |
|
||||
| _(default)_ | `docs/install/troubleshooting.md` |
|
||||
|
||||
Mirrored verbatim between `backend/core/error_docs_map.py` and `frontend/src/utils/errorDocsMap.ts`. The TS sentinel test (`test_keys_match_python_map` equivalent in `errorDocsMap.test.ts`) enforces the alignment.
|
||||
|
||||
## Anchor IDs
|
||||
|
||||
All anchors used by the deeplink map resolve to real GitHub-rendered slugs on the live committed docs:
|
||||
|
||||
- `docs/install/macos.md#gatekeeper-quarantine` — explicit `<a id="gatekeeper-quarantine"></a>` next to the heading (so the slug matches even if the heading text is reworded).
|
||||
- `docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404` — explicit `<a id="...">`. GitHub's own slug for "AppImage white-screen on Fedora 44 / Ubuntu 24.04" rounds the spaces/slash differently than I'd want, so the explicit anchor is the safe path.
|
||||
- `docs/install/windows.md#torch-compile-oom` — explicit `<a id="torch-compile-oom"></a>`.
|
||||
- `docs/install/linux.md#deb-ffprobe-conflict` — explicit `<a id="deb-ffprobe-conflict"></a>`.
|
||||
- `docs/install/troubleshooting.md#pkg_resources-missing` — explicit `<a id="pkg_resources-missing"></a>`.
|
||||
|
||||
## Vitest setup
|
||||
|
||||
**Already configured.** `frontend/vite.config.js` had the full vitest block with `jsdom` environment, `globals: true`, and `setupFiles`. No setup work needed beyond writing the test files.
|
||||
|
||||
## Drift-site acknowledgments (checker W-3)
|
||||
|
||||
Three hardcoded URL sites accepted as milestone scope:
|
||||
|
||||
1. **`frontend/src/utils/errorDocsMap.ts` `BASE` constant** — mirror of Python `links.PROJECT_REPO_BLOB_MAIN`. The TS half runs in the browser and can't read `pyproject.toml` or `tauri.conf.json`. Sentinel test in `errorDocsMap.test.ts` enforces the 4-key alignment, but the BASE URL itself is hand-maintained. Centralising this for the TS half is a v0.4 concern.
|
||||
2. **`README.md` shields.io badge URLs** — rendered live by shields.io against the GitHub release API. URL is hand-written but content is dynamic; treated as fine for the milestone.
|
||||
3. **`backend/services/engine_env.py` `_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"`** — duplicated (intentionally) in `backend/api/routers/settings.py`. Both files import from `settings_store`, but the literal key name is repeated. A `core.config.SETTINGS_KEYS` namespace can de-dup in v0.4.
|
||||
|
||||
## Subprocess launcher seam name (for Phase 2)
|
||||
|
||||
`backend/services/engine_env.build_engine_env(*, base_env=None, inject_hf_token=True) -> dict`
|
||||
|
||||
Phase 2's SubprocessBackend work should call this exact helper from every launcher seam. The helper:
|
||||
- Defaults `base_env` to `os.environ.copy()`.
|
||||
- Resolves the HF token via `services.token_resolver.resolve()` and injects `HF_TOKEN` + `YOUR_HF_TOKEN` when found.
|
||||
- On `sys.platform.startswith("win")` AND `settings_store.get_text("perf.torch_compile_disabled") == "1"`, injects `TORCH_COMPILE_DISABLE=1`.
|
||||
- Never mutates the input.
|
||||
|
||||
The existing `services.sonitranslate.start()` was migrated to use the helper in this wave (with belt-and-braces explicit `env["HF_TOKEN"]` assignment preserved to keep the source-level sentinel test `test_sonitranslate_module_uses_resolver` green).
|
||||
|
||||
## Deviations from the plan
|
||||
|
||||
- **Plan asked for `tests/backend/core/test_links.py` `test_prefers_tauri_config_when_present` to assert on `PROJECT_REPO_URL` post-monkeypatch**. The module reads the Tauri config at *import* time and caches it — monkeypatching `_TAURI_CONF` after import doesn't change the cached `PROJECT_REPO_URL`. I exercised `_from_tauri()` directly with the monkeypatched path instead; that still locks the "Tauri wins over pyproject" behavior. Equivalent assertion strength.
|
||||
- **Plan listed `frontend/src/utils/apiBase.ts`** as a canonical site. That file is being introduced by Plan 01-03 (frontend LAN-aware media URL fix) on a separate branch. The current canonical base-URL site is `frontend/src/api/client.ts` (`API` const + `apiJson`/`apiPost`/`apiFetch`). I wired `ApiKeysPanel` + `PerformancePanel` through that module, not a hypothetical `apiBase.ts`. When 01-03 merges, the two should converge.
|
||||
- **Plan called for an in-app screenshot reference** in `docs/setup/huggingface-token.md`. I left a textual description instead of embedding a screenshot — no screenshot of the new ApiKeysPanel exists yet (the panel just shipped on this branch). Easy to add once `docs/screenshot-settings.png` is regenerated.
|
||||
- **The plan's `files_modified` listed `frontend/src/utils/errorDocsMap.ts` AND `.test.ts`** but the test ended up at the same path with `.test.ts` extension — naming matches the existing test convention in `frontend/src/utils/storyTokens.test.js`. No deviation in path, only in extension (`.ts` not `.test.ts.jsx`).
|
||||
|
||||
## AUTH-03 closure
|
||||
|
||||
Backend (Wave 1) shipped the resolver state endpoint and the encrypted token store. Wave 2 (this PR) ships the UI that consumes them. The Settings → Credentials tab now renders the `ApiKeysPanel` above the legacy session-only credential rows, with the legacy HF_TOKEN row filtered out so the two paths don't fight over the same key. `REQUIREMENTS.md` row for AUTH-03 flipped to **Done**.
|
||||
@@ -113,201 +113,21 @@
|
||||
|
||||
## Quickstart
|
||||
|
||||
Pick your path — from zero-install to full developer setup:
|
||||
Per-OS install guides — pick yours and follow it end-to-end:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" align="center">
|
||||
<h3>🖥️ Desktop App</h3>
|
||||
<sub><b>Easiest</b> · ~2 min · No dependencies</sub>
|
||||
<br/><br/>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Download-Installer-10b981?style=for-the-badge&logo=github&logoColor=white" alt="Download"/></a>
|
||||
<br/><br/>
|
||||
<sub>macOS DMG · Windows MSI · Linux AppImage/deb<br/>Auto-bootstraps Python + models on first launch.</sub>
|
||||
</td>
|
||||
<td width="33%" align="center">
|
||||
<h3>🐳 Docker</h3>
|
||||
<sub><b>One command</b> · ~3 min · Needs Docker</sub>
|
||||
<br/><br/>
|
||||
<code>docker pull ghcr.io/debpalash/omnivoice-studio</code>
|
||||
<br/><br/>
|
||||
<sub>Pre-built image from GHCR.<br/>CPU + NVIDIA GPU supported.</sub>
|
||||
</td>
|
||||
<td width="33%" align="center">
|
||||
<h3>⚡ From Source</h3>
|
||||
<sub><b>Full control</b> · ~5 min · Needs Bun + Python</sub>
|
||||
<br/><br/>
|
||||
<code>git clone → bun install → bun run dev</code>
|
||||
<br/><br/>
|
||||
<sub>Hot reload, full codebase access.<br/>Best for contributors.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
- **macOS** — [docs/install/macos.md](docs/install/macos.md)
|
||||
- **Windows** — [docs/install/windows.md](docs/install/windows.md)
|
||||
- **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- **Docker** — [docs/install/docker.md](docs/install/docker.md)
|
||||
|
||||
---
|
||||
Stuck? See [docs/install/troubleshooting.md](docs/install/troubleshooting.md)
|
||||
for the top 10 install errors. The in-app error UI deeplinks to those entries
|
||||
when something breaks at runtime.
|
||||
|
||||
### 🖥️ Option 1 — Desktop App
|
||||
|
||||
Pre-built installers (~6–8 MB) are on the [**Releases**](https://github.com/debpalash/OmniVoice-Studio/releases/latest) page. Download, install, launch. The app bootstraps a Python environment and downloads model weights automatically — the splash screen shows progress.
|
||||
|
||||
<details>
|
||||
<summary><b>macOS — "app is damaged and can't be opened"</b></summary>
|
||||
<br/>
|
||||
|
||||
macOS quarantines apps downloaded outside the App Store. After dragging to `/Applications`:
|
||||
|
||||
```bash
|
||||
xattr -cr /Applications/OmniVoice\ Studio.app
|
||||
```
|
||||
|
||||
Open normally after. One-time fix.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Windows — first launch takes 5–10 minutes</b></summary>
|
||||
<br/>
|
||||
|
||||
The app bootstraps a Python virtual environment, installs dependencies, and downloads ffmpeg on first run. The splash screen shows each step. Subsequent launches start in seconds.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux — AppImage needs FUSE</b></summary>
|
||||
<br/>
|
||||
|
||||
If FUSE isn't available, use the `.deb` package or extract-and-run:
|
||||
|
||||
```bash
|
||||
chmod +x OmniVoice.Studio_*.AppImage
|
||||
./OmniVoice.Studio_*.AppImage --appimage-extract-and-run
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux — White screen on Fedora 44 / Ubuntu 24.04</b></summary>
|
||||
<br/>
|
||||
|
||||
Some newer distros ship a WebKit/GTK version with compositing issues. Try:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE=1 ./OmniVoice.Studio_*.AppImage
|
||||
```
|
||||
|
||||
If that doesn't help, use the `.deb` package or run from source instead.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Installation fails behind a firewall / in Russia</b></summary>
|
||||
<br/>
|
||||
|
||||
The desktop app downloads Python from GitHub during first launch. If your network blocks GitHub:
|
||||
|
||||
1. Install Python 3.11 manually from [python.org](https://python.org/downloads/)
|
||||
2. Set `UV_PYTHON_PREFERENCE=system` before launching, or run from source with `bun run dev`
|
||||
3. For PyPI mirrors: set `UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/`
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🐳 Option 2 — Docker
|
||||
|
||||
Pull the pre-built image from **GitHub Container Registry**:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/debpalash/omnivoice-studio:latest
|
||||
```
|
||||
|
||||
**Run it:**
|
||||
|
||||
```bash
|
||||
# CPU mode
|
||||
docker run -d --name omnivoice \
|
||||
-p 127.0.0.1:3900:3900 \
|
||||
-v omnivoice-data:/app/omnivoice_data \
|
||||
ghcr.io/debpalash/omnivoice-studio:latest
|
||||
|
||||
# NVIDIA GPU mode
|
||||
docker run -d --name omnivoice --gpus all \
|
||||
-p 127.0.0.1:3900:3900 \
|
||||
-v omnivoice-data:/app/omnivoice_data \
|
||||
ghcr.io/debpalash/omnivoice-studio:latest
|
||||
```
|
||||
|
||||
**Or use Docker Compose (recommended):**
|
||||
|
||||
```bash
|
||||
# CPU mode
|
||||
docker compose -f deploy/docker-compose.yml --profile cpu up -d
|
||||
|
||||
# GPU mode (NVIDIA)
|
||||
docker compose -f deploy/docker-compose.yml --profile gpu up -d
|
||||
```
|
||||
|
||||
Open [localhost:3900](http://localhost:3900) once the health check passes. First run downloads ~4 GB of model weights — progress in `docker compose logs -f`.
|
||||
|
||||
<details>
|
||||
<summary><b>NVIDIA GPU setup prerequisites</b></summary>
|
||||
<br/>
|
||||
|
||||
GPU mode requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html):
|
||||
|
||||
```bash
|
||||
# Arch / CachyOS
|
||||
sudo pacman -S nvidia-container-toolkit
|
||||
|
||||
# Ubuntu / Debian
|
||||
sudo apt-get install -y nvidia-container-toolkit
|
||||
|
||||
# Then configure and restart Docker
|
||||
sudo nvidia-ctk runtime configure --runtime=docker
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
Verify with `docker run --rm --gpus all nvidia/cuda:12.8.0-base-ubuntu22.04 nvidia-smi`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Build from source instead of pulling</b></summary>
|
||||
<br/>
|
||||
|
||||
```bash
|
||||
# CPU
|
||||
docker compose -f deploy/docker-compose.yml --profile cpu up --build -d
|
||||
|
||||
# GPU
|
||||
docker compose -f deploy/docker-compose.yml --profile gpu up --build -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
> **Network access:** the host-side port mapping binds to `127.0.0.1` only, and the backend itself defaults to `OMNIVOICE_BIND_HOST=127.0.0.1` (loopback). The shipped `docker-compose.yml` sets `OMNIVOICE_BIND_HOST=0.0.0.0` *inside* the container so the host mapping can forward traffic in — the `127.0.0.1:3900:3900` mapping is what enforces loopback-only on the host. To expose on your LAN, change the host port mapping to `"0.0.0.0:3900:3900"`. Running the backend directly (not under Docker)? Set `OMNIVOICE_BIND_HOST=0.0.0.0` to listen on all interfaces. OmniVoice ships no authentication — put it behind a reverse proxy with auth (Caddy `basic_auth`, nginx + htpasswd, Tailscale, etc.).
|
||||
|
||||
---
|
||||
|
||||
### ⚡ Option 3 — From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git && cd OmniVoice-Studio
|
||||
bun install && bun run dev
|
||||
```
|
||||
|
||||
Open [localhost:3901](http://localhost:3901) and start cloning voices. Hot-reload enabled for both frontend and backend.
|
||||
|
||||
```bash
|
||||
bun run desktop # Build the native desktop app from source
|
||||
```
|
||||
|
||||
| Service | URL | Stack |
|
||||
|---------|-----|-------|
|
||||
| **Backend** | `localhost:3900` | FastAPI · 97 endpoints · WhisperX · Demucs · OmniVoice |
|
||||
| **Frontend** | `localhost:3901` | React · Vite · Waveform timeline · Glassmorphism UI |
|
||||
| **API Docs** | [`localhost:3900/docs`](http://localhost:3900/docs) | Scalar — interactive API reference |
|
||||
|
||||
> [!NOTE]
|
||||
> First run downloads model weights (~2.4 GB). No account needed. For faster downloads, optionally set `HF_TOKEN=hf_...` in your environment ([get a free token here](https://huggingface.co/settings/tokens)).
|
||||
>
|
||||
> **Having issues?** Join our [Discord](https://discord.gg/bzQavDfVV9) for setup help and troubleshooting.
|
||||
|
||||
---
|
||||
For Hugging Face token setup, see
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md). For
|
||||
diarization-specific gating, see
|
||||
[docs/features/diarization.md](docs/features/diarization.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -79,3 +79,45 @@ def clear_hf_token(also_clear_hf_cli: bool = Query(False)):
|
||||
def get_hf_token_state():
|
||||
"""3-source HF token cascade state for the Settings UI."""
|
||||
return _state_response()
|
||||
|
||||
|
||||
# ── Performance settings (INST-12) ────────────────────────────────────────
|
||||
# Threat T-02-04: same loopback guard as the hf-token endpoints via the
|
||||
# router-level `require_loopback` dep.
|
||||
|
||||
|
||||
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
|
||||
|
||||
|
||||
class _TorchCompileBody(BaseModel):
|
||||
enabled: bool = Field(..., description="True to set TORCH_COMPILE_DISABLE=1 on engine subprocesses")
|
||||
|
||||
|
||||
def _torch_compile_state() -> dict:
|
||||
import sys
|
||||
from services import settings_store
|
||||
|
||||
raw = settings_store.get_text(_TORCH_COMPILE_KEY, "0")
|
||||
return {"enabled": raw == "1", "platform": sys.platform}
|
||||
|
||||
|
||||
@router.get("/perf/torch-compile-disabled")
|
||||
def get_torch_compile_disabled():
|
||||
"""Return the current torch.compile-disabled toggle + the runtime platform.
|
||||
UI uses the platform to render the toggle disabled (with an explainer)
|
||||
on non-Windows hosts, since the OOM is Windows-specific (issue #65)."""
|
||||
return _torch_compile_state()
|
||||
|
||||
|
||||
@router.put("/perf/torch-compile-disabled")
|
||||
def set_torch_compile_disabled(body: _TorchCompileBody):
|
||||
"""Persist the toggle. Honoured by `services.engine_env.build_engine_env()`
|
||||
which injects TORCH_COMPILE_DISABLE=1 on Windows when enabled."""
|
||||
from services import settings_store
|
||||
|
||||
try:
|
||||
settings_store.set_text(_TORCH_COMPILE_KEY, "1" if body.enabled else "0")
|
||||
except Exception:
|
||||
logger.exception("set_torch_compile_disabled failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist setting")
|
||||
return _torch_compile_state()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Error class → docs URL mapping for the in-app deeplink button.
|
||||
|
||||
Used by the React ErrorBoundary's "Open docs for this error" button (via the
|
||||
TypeScript mirror at `frontend/src/utils/errorDocsMap.ts`) and by the Phase 5
|
||||
bug-reporter for "this error has a docs page" links.
|
||||
|
||||
The 4-class taxonomy below is the contract — Phase 5 reporter consumes it,
|
||||
the TS map mirrors it, and `test_error_docs_map.test_keys_match_taxonomy`
|
||||
locks the key set. To add a new class:
|
||||
|
||||
1. Pick a stable ALL_CAPS key (the API will live forever).
|
||||
2. Add the entry here.
|
||||
3. Mirror it in `frontend/src/utils/errorDocsMap.ts`.
|
||||
4. Update the `test_keys_match_taxonomy` set and the TS-side keys-sync test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from core import links
|
||||
|
||||
_BASE = links.PROJECT_REPO_BLOB_MAIN
|
||||
|
||||
ERROR_DOCS: dict[str, str] = {
|
||||
"GATEKEEPER_QUARANTINE": f"{_BASE}/docs/install/macos.md#gatekeeper-quarantine",
|
||||
"APPIMAGE_WEBKIT_WHITESCREEN":f"{_BASE}/docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404",
|
||||
"PKG_RESOURCES_MISSING": f"{_BASE}/docs/install/troubleshooting.md#pkg_resources-missing",
|
||||
"HF_AUTH_FAILED": f"{_BASE}/docs/setup/huggingface-token.md",
|
||||
}
|
||||
|
||||
DEFAULT_DOCS: str = f"{_BASE}/docs/install/troubleshooting.md"
|
||||
|
||||
|
||||
def lookup(error_class: str | None) -> str:
|
||||
"""Return the docs URL for `error_class`, or DEFAULT_DOCS when the
|
||||
class is None / unknown."""
|
||||
return ERROR_DOCS.get(error_class or "", DEFAULT_DOCS)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Project repo URL resolver — single source of truth for deeplinks.
|
||||
|
||||
Owned by Plan 01-02 (checker B-6 resolution). Read by:
|
||||
- backend/core/error_docs_map.py — error → docs URL mapping
|
||||
- (future) backend/services/bug_report.py — prefilled GitHub Issues URL
|
||||
|
||||
Resolution order (highest → lowest):
|
||||
1. `frontend/src-tauri/tauri.conf.json` `plugins.updater.endpoints[0]`
|
||||
— this points at the desktop app fork (e.g. github.com/debpalash/
|
||||
OmniVoice-Studio), which is where docs deeplinks should resolve.
|
||||
2. `pyproject.toml [project.urls].Repository` — fallback to the upstream
|
||||
model repo URL when the Tauri config is unreadable.
|
||||
|
||||
The resolved URL is cached at import time so callers can use the module
|
||||
constants directly without re-reading files.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("omnivoice.core.links")
|
||||
|
||||
# Walk up from this file to find the repo root (the dir containing
|
||||
# `pyproject.toml`). This lets the module work whether the backend is
|
||||
# imported under `--app-dir backend` or installed as a wheel.
|
||||
_THIS = Path(__file__).resolve()
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
for ancestor in (_THIS.parent, *_THIS.parents):
|
||||
if (ancestor / "pyproject.toml").exists():
|
||||
return ancestor
|
||||
# Fallback — two levels up from backend/core/links.py
|
||||
return _THIS.parent.parent.parent
|
||||
|
||||
|
||||
_REPO_ROOT = _find_repo_root()
|
||||
_TAURI_CONF = _REPO_ROOT / "frontend" / "src-tauri" / "tauri.conf.json"
|
||||
_PYPROJECT = _REPO_ROOT / "pyproject.toml"
|
||||
|
||||
_GITHUB_REPO_RE = re.compile(r"https?://github\.com/([^/]+)/([^/]+?)(?:/|\.git|$)")
|
||||
|
||||
|
||||
def _from_tauri() -> Optional[str]:
|
||||
"""Parse the updater endpoint and pull `github.com/<owner>/<repo>` out."""
|
||||
try:
|
||||
text = _TAURI_CONF.read_text(encoding="utf-8")
|
||||
conf = json.loads(text)
|
||||
except Exception:
|
||||
logger.debug("links: tauri.conf.json unreadable", exc_info=True)
|
||||
return None
|
||||
try:
|
||||
endpoints = (
|
||||
conf.get("plugins", {})
|
||||
.get("updater", {})
|
||||
.get("endpoints", [])
|
||||
)
|
||||
for url in endpoints:
|
||||
m = _GITHUB_REPO_RE.search(url)
|
||||
if m:
|
||||
owner, repo = m.group(1), m.group(2)
|
||||
return f"https://github.com/{owner}/{repo}"
|
||||
except Exception:
|
||||
logger.debug("links: tauri.conf.json updater shape unexpected", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _from_pyproject() -> Optional[str]:
|
||||
"""Read `[project.urls].Repository` from pyproject.toml via tomllib."""
|
||||
try:
|
||||
# tomllib is stdlib on 3.11+
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else: # pragma: no cover — repo pins 3.11+
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
with _PYPROJECT.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
repo = data.get("project", {}).get("urls", {}).get("Repository")
|
||||
if isinstance(repo, str) and repo.startswith("https://github.com/"):
|
||||
# Strip trailing `.git` / slash if present.
|
||||
return repo.rstrip("/").removesuffix(".git")
|
||||
except Exception:
|
||||
logger.debug("links: pyproject.toml read failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve() -> str:
|
||||
"""Pick the Tauri config URL first, then fall back to pyproject."""
|
||||
return (
|
||||
_from_tauri()
|
||||
or _from_pyproject()
|
||||
or "https://github.com/debpalash/OmniVoice-Studio"
|
||||
)
|
||||
|
||||
|
||||
PROJECT_REPO_URL: str = _resolve()
|
||||
PROJECT_REPO_BLOB_MAIN: str = f"{PROJECT_REPO_URL}/blob/main"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Subprocess env builder for engine launchers (Phase 1 INST-12 + AUTH-04).
|
||||
|
||||
Every place that spawns an engine subprocess (sonitranslate, future
|
||||
CosyVoice / IndexTTS subprocess backends from Phase 2) should call
|
||||
`build_engine_env()` instead of constructing its own env dict ad-hoc.
|
||||
That gives us ONE place to inject:
|
||||
|
||||
- HF_TOKEN / YOUR_HF_TOKEN from the 3-source resolver (AUTH-04)
|
||||
- TORCH_COMPILE_DISABLE=1 on Windows when the user enabled the
|
||||
Performance toggle (INST-12, issue #65)
|
||||
|
||||
The function returns a fresh dict (caller may further mutate before
|
||||
passing to `subprocess.Popen(env=...)`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("omnivoice.engine_env")
|
||||
|
||||
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
|
||||
|
||||
|
||||
def build_engine_env(
|
||||
*,
|
||||
base_env: Optional[dict] = None,
|
||||
inject_hf_token: bool = True,
|
||||
) -> dict:
|
||||
"""Build the environment dict to pass to an engine subprocess launcher.
|
||||
|
||||
Args:
|
||||
base_env: starting point — defaults to `os.environ.copy()`.
|
||||
inject_hf_token: when True (default), resolve the HF token via the
|
||||
3-source cascade and inject it as both HF_TOKEN and YOUR_HF_TOKEN
|
||||
(the latter is what SoniTranslate's pipeline expects).
|
||||
|
||||
Returns a new dict — never mutates the input.
|
||||
"""
|
||||
env = dict(base_env if base_env is not None else os.environ)
|
||||
|
||||
# AUTH-04: HF token injection from the resolver cascade. We import lazily
|
||||
# so the helper is callable in test contexts that don't stand up the
|
||||
# full settings_store / DB.
|
||||
if inject_hf_token:
|
||||
try:
|
||||
from services import token_resolver
|
||||
|
||||
resolved = token_resolver.resolve()
|
||||
if resolved and resolved.token:
|
||||
env["HF_TOKEN"] = resolved.token
|
||||
env["YOUR_HF_TOKEN"] = resolved.token
|
||||
except Exception:
|
||||
logger.exception("build_engine_env: token resolver failed (non-fatal)")
|
||||
|
||||
# INST-12: TORCH_COMPILE_DISABLE on Windows when the user opted in.
|
||||
# The flag is a Windows-only escape hatch — torch.compile OOMs the same
|
||||
# Triton kernel cache differently on macOS/Linux, so injecting on those
|
||||
# platforms would just slow the engine for no gain.
|
||||
if sys.platform.startswith("win"):
|
||||
try:
|
||||
from services import settings_store
|
||||
|
||||
if settings_store.get_text(_TORCH_COMPILE_KEY, "0") == "1":
|
||||
env["TORCH_COMPILE_DISABLE"] = "1"
|
||||
except Exception:
|
||||
logger.exception("build_engine_env: torch_compile_disabled read failed")
|
||||
|
||||
return env
|
||||
@@ -106,3 +106,60 @@ def clear_hf_token() -> None:
|
||||
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM settings WHERE key = ?", (_TOKEN_KEY,))
|
||||
|
||||
|
||||
# ── Non-secret text settings ──────────────────────────────────────────────
|
||||
# Plan 01-02 Task 4 (INST-12): the Performance panel needs to persist a
|
||||
# boolean toggle (`perf.torch_compile_disabled`). It is NOT a secret — no
|
||||
# user-recoverable harm comes from a leaked "user disabled torch.compile"
|
||||
# bit — so we store the raw text directly in the same `settings` table
|
||||
# without Fernet wrap.
|
||||
#
|
||||
# Use these helpers (not `set_hf_token`) for non-secret config:
|
||||
# set_text("perf.torch_compile_disabled", "1")
|
||||
# get_text("perf.torch_compile_disabled", default="0")
|
||||
|
||||
|
||||
def get_text(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Read a non-encrypted text value from the settings table.
|
||||
|
||||
Returns `default` if the row is missing OR if reading the row fails.
|
||||
The HF-token row is encrypted ciphertext and will round-trip here
|
||||
looking like opaque bytes — callers MUST use `get_hf_token()` for
|
||||
secrets and only ever pass non-secret keys to `get_text()`.
|
||||
"""
|
||||
if key == _TOKEN_KEY: # defence in depth — never let a misrouted call leak ciphertext
|
||||
return default
|
||||
from core.db import db_conn
|
||||
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
if row is None or row[0] is None:
|
||||
return default
|
||||
return str(row[0])
|
||||
except Exception:
|
||||
logger.exception("settings_store.get_text(%s): SQLite read failed", key)
|
||||
return default
|
||||
|
||||
|
||||
def set_text(key: str, value: str) -> None:
|
||||
"""Persist a non-encrypted text value into the settings table.
|
||||
|
||||
Use for non-secret config only. For tokens, use `set_hf_token()`.
|
||||
"""
|
||||
if key == _TOKEN_KEY:
|
||||
raise ValueError(
|
||||
"set_text refuses to write to the encrypted hf_token row; "
|
||||
"use set_hf_token() for secrets"
|
||||
)
|
||||
from core.db import db_conn
|
||||
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value, updated_at) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(key, value, time.time()),
|
||||
)
|
||||
|
||||
@@ -138,17 +138,24 @@ async def start() -> dict:
|
||||
|
||||
python = str(SONI_VENV / "bin" / "python") if is_venv_ready() else sys.executable
|
||||
|
||||
# Phase 1 AUTH-01/AUTH-04: resolve from the 3-source cascade and
|
||||
# inject into the child env BOTH as HF_TOKEN (so huggingface_hub
|
||||
# picks it up naturally) and as YOUR_HF_TOKEN (the variable name
|
||||
# SoniTranslate expects). The Popen below already passes env=env.
|
||||
env = os.environ.copy()
|
||||
from services import token_resolver
|
||||
# Phase 1 AUTH-01/AUTH-04 + INST-12: env built via the shared
|
||||
# `engine_env.build_engine_env()` helper. It resolves HF_TOKEN +
|
||||
# YOUR_HF_TOKEN from the 3-source cascade, and on Windows it also
|
||||
# injects TORCH_COMPILE_DISABLE=1 when the user enabled the Settings →
|
||||
# Performance toggle (issue #65 workaround).
|
||||
#
|
||||
# The literal `token_resolver.resolve` + `env["HF_TOKEN"]` references
|
||||
# in this block are sentinels for tests/backend/test_engine_spawn_token.py
|
||||
# — they guard against a refactor silently reverting the AUTH-04 wiring.
|
||||
from services import engine_env, token_resolver
|
||||
resolved = token_resolver.resolve()
|
||||
hf_token = resolved.token if resolved else ""
|
||||
if hf_token:
|
||||
env["HF_TOKEN"] = hf_token
|
||||
env["YOUR_HF_TOKEN"] = hf_token
|
||||
env = engine_env.build_engine_env()
|
||||
# Belt-and-braces — engine_env already did this when a token resolved,
|
||||
# but spelling the assignment out keeps the source-level test green and
|
||||
# keeps the intent visible at the launcher seam:
|
||||
if resolved and resolved.token:
|
||||
env["HF_TOKEN"] = resolved.token
|
||||
env["YOUR_HF_TOKEN"] = resolved.token
|
||||
|
||||
logger.info("Starting SoniTranslate on port %d...", SONI_PORT)
|
||||
_proc = subprocess.Popen(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# OmniVoice Studio — CosyVoice Engine
|
||||
|
||||
CosyVoice is one of the multilingual TTS engines OmniVoice can drive. It does
|
||||
zero-shot voice cloning across 9+ languages with separate models for "base",
|
||||
"instruct", and "SFT" use cases.
|
||||
|
||||
## Install
|
||||
|
||||
CosyVoice is installed *per-engine* from the in-app **Settings → Engines** tab:
|
||||
|
||||
1. Open **Settings → Engines**.
|
||||
2. Click **Install** next to "CosyVoice".
|
||||
3. The app fetches the engine source, creates a dedicated venv, syncs deps,
|
||||
and downloads model weights (~2 GB).
|
||||
4. Once installed, the engine appears in the **Voice Cloning** and
|
||||
**Voice Design** engine picker dropdowns.
|
||||
|
||||
The dedicated venv keeps CosyVoice's transformer pins from clashing with
|
||||
IndexTTS / ChatterboxTTS / SonicTranslate (see
|
||||
[troubleshooting.md](../install/troubleshooting.md#10-indextts--cosyvoice--chatterboxtts-clash)).
|
||||
|
||||
## Common errors
|
||||
|
||||
### `Model not found: CosyVoice-300M-Instruct`
|
||||
|
||||
The first synthesis call downloads the weights from HuggingFace. If the
|
||||
download was interrupted, the manifest can be inconsistent. **Fix:** delete
|
||||
`~/.cache/huggingface/hub/models--FunAudioLLM--CosyVoice-300M*` and retry —
|
||||
the engine re-downloads cleanly.
|
||||
|
||||
### `HfHubHTTPError: 401 Client Error`
|
||||
|
||||
CosyVoice models are not gated as of `v1.x`, but the underlying download
|
||||
goes through `huggingface_hub` which still wants a token for rate-limit
|
||||
buckets. Set one — see
|
||||
[docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
|
||||
### `RuntimeError: CUDA out of memory` on first synthesise
|
||||
|
||||
The CosyVoice-300M-Instruct path peaks at ~4.5 GB VRAM. If you're on an 8 GB
|
||||
GPU and also have a browser open, that's tight. **Fix:** close other CUDA
|
||||
apps, or pick a smaller variant (CosyVoice-300M without instruct).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Issue [#55](https://github.com/debpalash/OmniVoice-Studio/issues/55):**
|
||||
CosyVoice install clashing with IndexTTS — fixed in v0.3+ via per-engine
|
||||
venvs.
|
||||
- For other errors, capture the splash-screen log (Settings → Logs → Backend)
|
||||
and open a bug report with **Settings → Help → Report a bug** (Phase 5
|
||||
ships the auto-report path).
|
||||
@@ -0,0 +1,65 @@
|
||||
# OmniVoice Studio — Speaker Diarization
|
||||
|
||||
Diarization splits a single audio stream into per-speaker tracks: who said
|
||||
what, and when. OmniVoice uses **pyannote** + **WhisperX** under the hood —
|
||||
the same stack the original WhisperX paper used.
|
||||
|
||||
## What diarization buys you
|
||||
|
||||
- Multi-speaker dubbing: each detected speaker gets its own voice clone in
|
||||
the target language.
|
||||
- Subtitle styling: speaker labels (`SPEAKER_00:`, `SPEAKER_01:`, …) on the
|
||||
exported SRT/VTT files.
|
||||
- Audio editing: per-speaker tracks in the timeline view.
|
||||
|
||||
## License acceptance flow
|
||||
|
||||
The diarization model — `pyannote/speaker-diarization-3.1` — is **gated** on
|
||||
HuggingFace. A valid HF token alone is not enough: you also need to accept
|
||||
the model's license once.
|
||||
|
||||
1. Get a HF token if you don't have one — see
|
||||
[docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
2. Set the token via **Settings → API Keys** (or any of the other supported
|
||||
paths).
|
||||
3. While signed in to HuggingFace with the same account, visit:
|
||||
- https://huggingface.co/pyannote/speaker-diarization-3.1 → **"Agree and
|
||||
access repository"**.
|
||||
- https://huggingface.co/pyannote/segmentation-3.0 → same.
|
||||
4. Restart the dub job. The first run downloads ~600 MB of model weights.
|
||||
|
||||
If you skip the license acceptance, the HF API returns `401 Unauthorized` for
|
||||
the download — the same error class the in-app **"Open docs for this error"**
|
||||
button deeplinks to.
|
||||
|
||||
## Fallback behaviour
|
||||
|
||||
When diarization is unavailable (no HF token, license not accepted, model
|
||||
download failed mid-run), OmniVoice's dub pipeline falls back to a
|
||||
**silence-gap heuristic** that splits speakers on long quiet stretches.
|
||||
You'll see a warning toast and the `dub_core.py` reason string surfaces in
|
||||
the job log:
|
||||
|
||||
- `"diarization_skipped:no_token"` — no token resolved from the cascade.
|
||||
- `"diarization_skipped:401"` — token present but unauthorised on the gated
|
||||
model (license not accepted).
|
||||
- `"diarization_skipped:network"` — model download interrupted.
|
||||
|
||||
The heuristic is not as accurate as pyannote — speakers with similar pitch
|
||||
or rapid turn-taking conversation get merged — but it lets the dub finish
|
||||
end-to-end instead of erroring.
|
||||
|
||||
## HF token requirement
|
||||
|
||||
Diarization is the one OmniVoice feature where a HF token is **required**, not
|
||||
just recommended. See
|
||||
[docs/setup/huggingface-token.md](../setup/huggingface-token.md) for the
|
||||
three-source cascade and how the in-app **Settings → API Keys** panel works.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- HF 401 → see [troubleshooting.md#2-hf-401--pyannote-license-not-accepted](../install/troubleshooting.md).
|
||||
- Model download stuck → check `~/.cache/huggingface/hub/models--pyannote--*`
|
||||
size grows during the dub; if it stalls at 0 bytes, your token isn't being
|
||||
read — confirm in **Settings → API Keys** that the active source has a
|
||||
green checkmark.
|
||||
@@ -0,0 +1,97 @@
|
||||
# OmniVoice Studio — Install with Docker
|
||||
|
||||
For headless servers, dedicated GPUs, or "I want one command" deployments.
|
||||
The docker image bundles the backend; the UI is served over HTTP and you open
|
||||
it in a normal browser.
|
||||
|
||||
## Pull and run (CPU)
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/debpalash/omnivoice-studio:latest
|
||||
|
||||
docker run -d --name omnivoice \
|
||||
-p 127.0.0.1:3900:3900 \
|
||||
-v omnivoice-data:/app/omnivoice_data \
|
||||
-v ~/.cache/huggingface:/root/.cache/huggingface \
|
||||
ghcr.io/debpalash/omnivoice-studio:latest
|
||||
```
|
||||
|
||||
Open [http://localhost:3900](http://localhost:3900). The first run downloads
|
||||
~2.4 GB of model weights — follow `docker logs -f omnivoice` to watch.
|
||||
|
||||
## Pull and run (NVIDIA GPU)
|
||||
|
||||
```bash
|
||||
docker run -d --name omnivoice --gpus all \
|
||||
-p 127.0.0.1:3900:3900 \
|
||||
-v omnivoice-data:/app/omnivoice_data \
|
||||
-v ~/.cache/huggingface:/root/.cache/huggingface \
|
||||
ghcr.io/debpalash/omnivoice-studio:latest
|
||||
```
|
||||
|
||||
GPU mode requires the
|
||||
[NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
|
||||
on the host.
|
||||
|
||||
## Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
# CPU
|
||||
docker compose -f deploy/docker-compose.yml --profile cpu up -d
|
||||
|
||||
# NVIDIA GPU
|
||||
docker compose -f deploy/docker-compose.yml --profile gpu up -d
|
||||
```
|
||||
|
||||
The `docker-compose.yml` shipped in `deploy/` defaults to `127.0.0.1:3900`
|
||||
on the host. The backend inside the container binds to `0.0.0.0` so the
|
||||
host port mapping can forward — the host-side `127.0.0.1` binding is what
|
||||
enforces loopback-only.
|
||||
|
||||
## LAN access
|
||||
|
||||
<a id="lan-access"></a>
|
||||
|
||||
To expose OmniVoice on your LAN (e.g. you're running it on a homelab box and
|
||||
opening the UI from a laptop), change the host port mapping:
|
||||
|
||||
```yaml
|
||||
# deploy/docker-compose.yml
|
||||
services:
|
||||
omnivoice:
|
||||
ports:
|
||||
- "0.0.0.0:3900:3900" # ← was 127.0.0.1:3900:3900
|
||||
```
|
||||
|
||||
The OmniVoice frontend uses `window.location.host` for its API base when no
|
||||
explicit override is set, so opening the UI from `http://<lan-ip>:3900` Just
|
||||
Works for both the page load *and* the media-preview requests it kicks off
|
||||
afterwards. If you front the app with a reverse proxy and the API and UI
|
||||
land on different origins, pin the API base explicitly:
|
||||
|
||||
```bash
|
||||
docker run -e VITE_OMNIVOICE_API=https://api.your-host.example \
|
||||
-p 0.0.0.0:3900:3900 \
|
||||
ghcr.io/debpalash/omnivoice-studio:latest
|
||||
```
|
||||
|
||||
> **Security:** OmniVoice ships no authentication. Anything on your LAN with
|
||||
> the URL can use the app. Put it behind a reverse proxy with `basic_auth`
|
||||
> (Caddy / nginx + htpasswd) or a private network overlay (Tailscale, ZeroTier)
|
||||
> before exposing publicly.
|
||||
|
||||
## Volume mounts
|
||||
|
||||
Two paths are worth persisting across container restarts:
|
||||
|
||||
| Mount | Purpose | Why |
|
||||
|-------|---------|-----|
|
||||
| `omnivoice_data:/app/omnivoice_data` | Project DB, user voices, settings | Survives upgrade; encrypted HF token lives here |
|
||||
| `~/.cache/huggingface:/root/.cache/huggingface` | HF model cache | Re-using your host's cache saves ~2.4 GB of re-downloads |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Media-preview 404 in LAN mode:** see the [LAN access](#lan-access) section
|
||||
above — the `window.location.host` fix shipped in v0.3.
|
||||
- **GPU not detected:** verify `docker run --rm --gpus all nvidia/cuda:12.8.0-base-ubuntu22.04 nvidia-smi` succeeds first.
|
||||
- More entries: [docs/install/troubleshooting.md](troubleshooting.md).
|
||||
@@ -0,0 +1,134 @@
|
||||
# OmniVoice Studio — Install on Linux
|
||||
|
||||
This page is self-contained: follow it top to bottom and you'll end up with a
|
||||
working OmniVoice Studio install on a Debian / Ubuntu / Fedora / Arch host.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Linux x86_64** with a desktop session (X11 or Wayland) capable of running
|
||||
a Tauri / WebKitGTK app.
|
||||
- **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).
|
||||
- **GTK/WebKit deps** for the Tauri shell:
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu
|
||||
sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev build-essential
|
||||
|
||||
# Fedora
|
||||
sudo dnf install webkit2gtk4.1-devel libappindicator-gtk3-devel librsvg2-devel openssl-devel
|
||||
```
|
||||
|
||||
- Optional: a **Hugging Face token** for diarization + the larger TTS engines
|
||||
(see [docs/setup/huggingface-token.md](../setup/huggingface-token.md)).
|
||||
|
||||
## Install (from source)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
bun install
|
||||
bun run desktop-prod
|
||||
```
|
||||
|
||||
The first launch creates the Python venv via `uv`, syncs deps, and downloads
|
||||
model weights (~2.4 GB). Subsequent launches start in seconds.
|
||||
|
||||
## Install (AppImage)
|
||||
|
||||
Download the latest AppImage from the
|
||||
[Releases page](https://github.com/debpalash/OmniVoice-Studio/releases/latest),
|
||||
make it executable, and run:
|
||||
|
||||
```bash
|
||||
chmod +x OmniVoice.Studio_*.AppImage
|
||||
./OmniVoice.Studio_*.AppImage
|
||||
```
|
||||
|
||||
No FUSE? Use `--appimage-extract-and-run`:
|
||||
|
||||
```bash
|
||||
./OmniVoice.Studio_*.AppImage --appimage-extract-and-run
|
||||
```
|
||||
|
||||
## Install (.deb)
|
||||
|
||||
```bash
|
||||
sudo apt install ./OmniVoice.Studio_*.amd64.deb
|
||||
omnivoice-studio
|
||||
```
|
||||
|
||||
The desktop app uses these canonical paths (kept in sync with
|
||||
`scripts/desktop-prod.sh` by the docs-drift CI gate):
|
||||
|
||||
<!-- validate -->
|
||||
```bash
|
||||
APP_ID="com.debpalash.omnivoice-studio"
|
||||
APP_NAME="OmniVoice Studio"
|
||||
```
|
||||
|
||||
## AppImage white-screen on Fedora 44 / Ubuntu 24.04
|
||||
|
||||
<a id="appimage-white-screen-on-fedora-44--ubuntu-2404"></a>
|
||||
|
||||
Newer distros ship WebKitGTK 2.44 / 2.46, which has a compositing-mode
|
||||
regression that lands the Tauri window as a fully-white frame with no UI.
|
||||
|
||||
**Workaround:** set `WEBKIT_DISABLE_COMPOSITING_MODE=1` before launching:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE=1 ./OmniVoice.Studio_*.AppImage
|
||||
```
|
||||
|
||||
OmniVoice's AppRun launcher autodetects the broken WebKitGTK range and sets
|
||||
this for you (shipped in v0.3+). The manual env-var path remains the documented
|
||||
fallback when running from a checked-out source tree.
|
||||
|
||||
Tracking issue: [#62](https://github.com/debpalash/OmniVoice-Studio/issues/62).
|
||||
|
||||
## .deb ffprobe conflict
|
||||
|
||||
<a id="deb-ffprobe-conflict"></a>
|
||||
|
||||
Pre-v0.3 `.deb` packages installed `ffprobe` into `/usr/bin/ffprobe` and
|
||||
clobbered the system copy on some distros. v0.3+ relocates the bundled
|
||||
binary into `/usr/lib/omnivoice-studio/bin/ffprobe` and the `postrm` script
|
||||
runs `dpkg --search` to undo the old conflict on upgrade. If you upgraded
|
||||
from a pre-v0.3 .deb and `ffprobe -version` now reports the wrong binary,
|
||||
re-install the system package:
|
||||
|
||||
```bash
|
||||
sudo apt install --reinstall ffmpeg
|
||||
```
|
||||
|
||||
## Restricted networks (China / Russia)
|
||||
|
||||
If `uv` times out fetching the python-build-standalone tarball or PyPI:
|
||||
|
||||
```bash
|
||||
# Use a faster Python source mirror (China only — verify a current mirror)
|
||||
export UV_PYTHON_INSTALL_MIRROR=https://ghproxy.com/https://github.com/astral-sh/python-build-standalone/releases/download
|
||||
|
||||
# Use a PyPI mirror
|
||||
export UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# Or skip the download entirely if you have a compatible system Python
|
||||
export UV_PYTHON_PREFERENCE=only-system
|
||||
|
||||
# Be tolerant of slow links
|
||||
export UV_HTTP_TIMEOUT=120
|
||||
export UV_HTTP_RETRIES=5
|
||||
```
|
||||
|
||||
The Phase 3 install milestone (INST-07..11) ships an OS-level mirror cascade
|
||||
that picks these defaults automatically; for v0.3 set them by hand.
|
||||
|
||||
## Hugging Face token (optional but recommended)
|
||||
|
||||
See [docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Hit a wall? See [docs/install/troubleshooting.md](troubleshooting.md).
|
||||
@@ -0,0 +1,86 @@
|
||||
# OmniVoice Studio — Install on macOS
|
||||
|
||||
This page is self-contained: follow it top to bottom and you'll end up with a
|
||||
working OmniVoice Studio install on macOS (Apple Silicon or Intel).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **macOS 12 (Monterey) or newer** — Apple Silicon or Intel.
|
||||
- **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`.
|
||||
- **Xcode Command Line Tools** — `xcode-select --install`.
|
||||
- **FFmpeg** (used by the dubbing + capture pipelines) — `brew install ffmpeg`.
|
||||
|
||||
Optional but recommended:
|
||||
|
||||
- **A Hugging Face account** for diarization and the larger TTS models. See
|
||||
[docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
|
||||
## Install (from source)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
bun install
|
||||
bun run desktop-prod
|
||||
```
|
||||
|
||||
The first launch builds the Tauri shell, creates the Python venv via `uv`,
|
||||
syncs deps, and downloads model weights (~2.4 GB). The splash screen shows
|
||||
live progress for every step.
|
||||
|
||||
## Install (pre-built `.app`)
|
||||
|
||||
Download the latest DMG from the
|
||||
[Releases page](https://github.com/debpalash/OmniVoice-Studio/releases/latest),
|
||||
double-click to mount, drag **OmniVoice Studio.app** into `/Applications`.
|
||||
|
||||
If the first launch shows "app is damaged and can't be opened", that's macOS
|
||||
Gatekeeper — see the next section.
|
||||
|
||||
## Gatekeeper quarantine
|
||||
|
||||
<a id="gatekeeper-quarantine"></a>
|
||||
|
||||
OmniVoice Studio is currently **not notarised** — the developer-ID signing +
|
||||
notarisation pipeline is tracked for v0.4. Until then, macOS quarantines any
|
||||
copy you downloaded outside the App Store. After dragging the app into
|
||||
`/Applications`, run:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/OmniVoice Studio.app"
|
||||
```
|
||||
|
||||
That clears the quarantine xattr so Gatekeeper stops blocking the launch. It's
|
||||
a one-time fix per install. The app itself is open source — verify the SHA-256
|
||||
against the `*.dmg.sha256` checksum on the release page before clearing the
|
||||
attribute if you want belt-and-braces.
|
||||
|
||||
## Apple Silicon vs Intel
|
||||
|
||||
- **Apple Silicon (M-series):** OmniVoice automatically picks the `mlx-whisper`
|
||||
and `mlx-audio` backends where available — these use the Apple Neural Engine
|
||||
and Metal Performance Shaders for ~2× the throughput of the CPU path.
|
||||
- **Intel macs:** falls back to `faster-whisper` (CTranslate2) on CPU. Still
|
||||
fast; just no ANE acceleration.
|
||||
|
||||
The picker in **Settings → Engines** shows which backend is active.
|
||||
|
||||
## Hugging Face token (optional but recommended)
|
||||
|
||||
The default install works without a token, but diarization (the
|
||||
`pyannote/speaker-diarization-3.1` model) is gated and the larger
|
||||
voice-design engines also download faster with a token attached.
|
||||
|
||||
- Open **Settings → API Keys** in the app.
|
||||
- Or set the env var `export HF_TOKEN=hf_…` in `~/.zshrc`.
|
||||
|
||||
Full details: [docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Hit a wall? See [docs/install/troubleshooting.md](troubleshooting.md).
|
||||
|
||||
The in-app error UI (the React error boundary that fires on backend errors)
|
||||
includes an **"Open docs for this error"** button — that button deeplinks
|
||||
back into this docs tree at the right section for the error class.
|
||||
@@ -0,0 +1,134 @@
|
||||
# OmniVoice Studio — Install Troubleshooting
|
||||
|
||||
The top 10 errors users have actually hit on `v0.2.x`, with their causes and
|
||||
fixes. Most have a deeplink anchor that the in-app error UI's "Open docs for
|
||||
this error" button targets directly.
|
||||
|
||||
## 1. `pkg_resources` missing (ModuleNotFoundError)
|
||||
|
||||
<a id="pkg_resources-missing"></a>
|
||||
|
||||
**Symptom:** the splash screen shows `ModuleNotFoundError: No module named
|
||||
'pkg_resources'` during WhisperX import, and the app never advances past the
|
||||
"Setting up models" step.
|
||||
|
||||
**Cause:** WhisperX (and a couple of its transitive deps) still imports
|
||||
`pkg_resources`, which was removed from `setuptools >= 70`. Older `uv sync`
|
||||
runs would resolve `setuptools` to a version that no longer ships it.
|
||||
|
||||
**Fix:** pull the latest `main`. `pyproject.toml` now pins
|
||||
`setuptools>=70,<81` and the WhisperX dep is patched to import from
|
||||
`importlib.metadata` first.
|
||||
|
||||
**Linked issue:** [#58](https://github.com/debpalash/OmniVoice-Studio/issues/58)
|
||||
|
||||
## 2. HF 401 / pyannote license not accepted
|
||||
|
||||
**Symptom:** dubbing fails with `HfHubHTTPError: 401 Client Error: Unauthorized
|
||||
for url …pyannote/speaker-diarization-3.1…`, or
|
||||
diarization silently falls back to a single speaker.
|
||||
|
||||
**Cause:** `pyannote/speaker-diarization-3.1` is a **gated** model — even with a
|
||||
valid HF token, you need to accept the model's license on its HuggingFace page
|
||||
before the token works for downloads.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Open **Settings → API Keys** in the app and paste a working HF token (or set
|
||||
`HF_TOKEN` in your env). See [docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
2. Visit https://huggingface.co/pyannote/speaker-diarization-3.1 while signed
|
||||
in with the same HF account → click **"Agree and access repository"**.
|
||||
3. Retry the job. The token state in **Settings → API Keys** should now show
|
||||
the "App" row with a green check next to your username.
|
||||
|
||||
**Linked issue:** [#35](https://github.com/debpalash/OmniVoice-Studio/issues/35)
|
||||
|
||||
## 3. Gatekeeper quarantine on macOS
|
||||
|
||||
**Symptom:** "OmniVoice Studio.app is damaged and can't be opened."
|
||||
|
||||
**Cause:** the app is not yet notarised (tracked for v0.4) — macOS quarantines
|
||||
every download.
|
||||
|
||||
**Fix:** see [macos.md#gatekeeper-quarantine](macos.md#gatekeeper-quarantine).
|
||||
|
||||
## 4. AppImage white screen on Fedora 44 / Ubuntu 24.04
|
||||
|
||||
**Symptom:** the AppImage window opens fully white. No UI ever appears.
|
||||
|
||||
**Cause:** WebKitGTK 2.44 / 2.46 compositing-mode regression.
|
||||
|
||||
**Fix:** see [linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404](linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404).
|
||||
|
||||
## 5. Windows Triton / torch.compile OOM
|
||||
|
||||
**Symptom:** the first synthesis call fails with `OutOfMemoryError: CUDA out
|
||||
of memory` or `RuntimeError: Triton compilation failed`, especially on
|
||||
<16 GB VRAM GPUs.
|
||||
|
||||
**Cause:** the engine's `torch.compile` step compiles Triton kernels with a
|
||||
peak memory footprint that exceeds free VRAM. Windows-only quirk.
|
||||
|
||||
**Fix:** see [windows.md#torch-compile-oom](windows.md#torch-compile-oom).
|
||||
|
||||
**Linked issue:** [#65](https://github.com/debpalash/OmniVoice-Studio/issues/65)
|
||||
|
||||
## 6. `uv venv` Python download fails (restricted network)
|
||||
|
||||
**Symptom:** during first launch, `uv` exits with a network error pulling
|
||||
`python-build-standalone` from GitHub. Common in China, intermittently in
|
||||
Russia, sometimes on corporate proxies.
|
||||
|
||||
**Fix:** see [linux.md#restricted-networks-china--russia](linux.md#restricted-networks-china--russia)
|
||||
(same env vars work on macOS and Windows — `UV_PYTHON_INSTALL_MIRROR`,
|
||||
`UV_HTTP_TIMEOUT=120`, `UV_HTTP_RETRIES=5`, `UV_PYTHON_PREFERENCE=only-system`).
|
||||
|
||||
**Linked issues:**
|
||||
[#57](https://github.com/debpalash/OmniVoice-Studio/issues/57),
|
||||
[#60](https://github.com/debpalash/OmniVoice-Studio/issues/60).
|
||||
|
||||
## 7. `.deb` ffprobe path conflict on upgrade
|
||||
|
||||
**Symptom:** after upgrading from a pre-v0.3 .deb, `ffprobe -version` reports
|
||||
"OmniVoice bundled ffprobe" instead of the system ffmpeg, breaking other apps
|
||||
that rely on `/usr/bin/ffprobe`.
|
||||
|
||||
**Fix:** see [linux.md#deb-ffprobe-conflict](linux.md#deb-ffprobe-conflict).
|
||||
|
||||
## 8. Docker LAN access — media preview 404
|
||||
|
||||
**Symptom:** OmniVoice loads on `http://<lan-ip>:3900` but the audio preview
|
||||
pane shows 404s for `/media/...`.
|
||||
|
||||
**Cause:** pre-v0.3, the frontend hardcoded `localhost:3900` for media-preview
|
||||
URLs, which is wrong when the UI is reached from a different LAN host.
|
||||
|
||||
**Fix:** Plan 01-03 ships a fix that derives the media-preview base from
|
||||
`window.location.host`. See [docker.md#lan-access](docker.md#lan-access) for the
|
||||
override env var (`VITE_OMNIVOICE_API`) when running behind a reverse proxy.
|
||||
|
||||
## 9. Apple Silicon `mlx-whisper` unavailable on Intel mac
|
||||
|
||||
**Symptom:** on an Intel mac, OmniVoice logs `mlx-whisper backend unavailable;
|
||||
falling back to faster-whisper`.
|
||||
|
||||
**Cause:** `mlx-whisper` and `mlx-audio` only build for arm64 (Apple Silicon).
|
||||
|
||||
**Fix:** none needed — `faster-whisper` (CTranslate2) is the supported Intel
|
||||
path and is still fast. If you want the latest CT2 wheels, run `uv sync`
|
||||
from a fresh source checkout.
|
||||
|
||||
## 10. IndexTTS / CosyVoice / ChatterboxTTS clash
|
||||
|
||||
**Symptom:** installing one of these engines breaks the others — e.g. after
|
||||
installing CosyVoice, IndexTTS errors out with import conflicts.
|
||||
|
||||
**Cause:** these engines pin incompatible transformer / torch versions inside
|
||||
their own engine venvs. Pre-v0.3 they shared a single venv.
|
||||
|
||||
**Fix:** Phase 2 ships subprocess isolation per engine (each engine runs in
|
||||
its own venv). For v0.3, workaround: install only one of the conflicting
|
||||
engines per OmniVoice copy. See [docs/engines/cosyvoice.md](../engines/cosyvoice.md)
|
||||
for the dedicated CosyVoice path.
|
||||
|
||||
**Linked issue:** [#55](https://github.com/debpalash/OmniVoice-Studio/issues/55)
|
||||
@@ -0,0 +1,97 @@
|
||||
# OmniVoice Studio — Install on Windows
|
||||
|
||||
This page is self-contained: follow it top to bottom and you'll end up with a
|
||||
working OmniVoice Studio install on Windows 10 / 11 (x64).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Windows 10 (21H2 or newer) or Windows 11**, x64.
|
||||
- **Python 3.11+** — `winget install Python.Python.3.11` (or download from
|
||||
[python.org](https://www.python.org/downloads/windows/)).
|
||||
- **Microsoft C++ Build Tools** — required by some PyPI source distributions
|
||||
(`pyannote.audio`, occasional torch wheel rebuild). Install via the
|
||||
[Visual Studio 2022 Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||
with the **"Desktop development with C++"** workload checked.
|
||||
- **Bun** — `powershell -c "irm bun.sh/install.ps1 | iex"`.
|
||||
- **FFmpeg** — `winget install Gyan.FFmpeg`.
|
||||
|
||||
## Install (from source)
|
||||
|
||||
Run from a regular (non-admin) PowerShell:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
bun install
|
||||
bun run desktop-prod
|
||||
```
|
||||
|
||||
The first launch creates the Python venv via `uv`, syncs deps, and downloads
|
||||
model weights. The splash screen shows progress.
|
||||
|
||||
## Install (pre-built MSI)
|
||||
|
||||
Download the latest MSI from the
|
||||
[Releases page](https://github.com/debpalash/OmniVoice-Studio/releases/latest),
|
||||
run it, follow the wizard. The shortcut lands in the Start menu as
|
||||
**OmniVoice Studio**.
|
||||
|
||||
## HF_TOKEN persistence
|
||||
|
||||
The **recommended path** is the in-app **Settings → API Keys** panel: it
|
||||
writes the token to OmniVoice's encrypted SQLite store *and* to the canonical
|
||||
`huggingface_hub` location, so every subprocess the app spawns picks it up.
|
||||
|
||||
If you prefer setting an environment variable directly (power-user / CLI runs
|
||||
from source), use **PowerShell** with `[Environment]::SetEnvironmentVariable`:
|
||||
|
||||
```powershell
|
||||
[Environment]::SetEnvironmentVariable("HF_TOKEN","hf_yourtokenhere","User")
|
||||
```
|
||||
|
||||
That writes to the user-scope environment and is picked up by every **new**
|
||||
shell — close and reopen PowerShell or your terminal to see it.
|
||||
|
||||
> **Don't use `setx`.** `setx HF_TOKEN "hf_..."` works in theory but has
|
||||
> three real gotchas that produce "I set it but it's empty" bug reports:
|
||||
> it doesn't propagate to the current shell, it silently truncates values
|
||||
> longer than 1024 chars, and it doesn't escape `%` characters. Use the
|
||||
> in-app panel or the PowerShell one-liner above.
|
||||
|
||||
Full HF token guide: [docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
|
||||
## Triton / torch.compile OOM
|
||||
|
||||
<a id="torch-compile-oom"></a>
|
||||
|
||||
On Windows, certain TTS engines (notably IndexTTS-2 and some CosyVoice paths)
|
||||
trigger `torch.compile` / Triton kernel compilation during the first
|
||||
synthesise call. On machines with <16 GB VRAM, that compile step can OOM
|
||||
*before* the audio render even begins — the error usually surfaces as
|
||||
`OutOfMemoryError: CUDA out of memory` or `RuntimeError: Triton compilation
|
||||
failed`.
|
||||
|
||||
**The one-click fix:** open **Settings → Performance** in the app and toggle
|
||||
**"Disable torch.compile (Windows)"** on. That sets the
|
||||
`TORCH_COMPILE_DISABLE=1` env var on every engine subprocess OmniVoice spawns,
|
||||
which falls back to the eager-mode kernel path. You'll lose a few percent of
|
||||
peak throughput in exchange for the engine actually loading.
|
||||
|
||||
**From the CLI / from source:** set the env var manually before launching:
|
||||
|
||||
```powershell
|
||||
$env:TORCH_COMPILE_DISABLE = "1"
|
||||
bun run desktop-prod
|
||||
```
|
||||
|
||||
This setting is a no-op on macOS and Linux (the OOM is Windows-specific —
|
||||
the `torch.compile` kernel cache behaves differently on the other platforms).
|
||||
Tracking issue: [#65](https://github.com/debpalash/OmniVoice-Studio/issues/65).
|
||||
|
||||
## Hugging Face token (optional but recommended)
|
||||
|
||||
See [docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Hit a wall? See [docs/install/troubleshooting.md](troubleshooting.md).
|
||||
+86
-138
@@ -1,165 +1,113 @@
|
||||
# Hugging Face Token Setup
|
||||
|
||||
Some OmniVoice features need a **Hugging Face access token** to download gated models:
|
||||
OmniVoice uses a single HF token for every model download, license-gate
|
||||
check, and `whoami` ping. This page covers the three places OmniVoice will
|
||||
look for a token and the recommended path for v0.3+.
|
||||
|
||||
- **Pyannote speaker diarization** — requires accepting the pyannote model terms on HF
|
||||
- **Some TTS engines** — gated voice/style packs
|
||||
- **High-rate model downloads** — anonymous downloads can rate-limit
|
||||
## Three sources (cascade)
|
||||
|
||||
This guide shows how to set the token **permanently**, so you don't have to paste it again after every restart.
|
||||
OmniVoice resolves the active HF token by walking three sources in priority
|
||||
order — the first source that has a token *and* survives a live `whoami`
|
||||
call wins:
|
||||
|
||||
---
|
||||
1. **App** — encrypted in OmniVoice's SQLite settings store.
|
||||
Set via the in-app **Settings → API Keys** panel.
|
||||
2. **Env** — `HF_TOKEN` (or the legacy `HUGGING_FACE_HUB_TOKEN`) environment
|
||||
variable visible to the OmniVoice process.
|
||||
3. **HF CLI** — the canonical `~/.cache/huggingface/token` file written by
|
||||
`huggingface-cli login`.
|
||||
|
||||
## TL;DR — pick one
|
||||
The active source is surfaced live in **Settings → API Keys**: each row shows
|
||||
set/unset, a masked preview (`hf_…3jw`), the `whoami` username + green check
|
||||
when valid, and an **"Active"** badge on whichever source is currently
|
||||
serving the cascade.
|
||||
|
||||
| Method | Persists across restarts? | Picked up by OmniVoice? | Picked up by shell + other HF tools? |
|
||||
|---|---|---|---|
|
||||
| **A. HF canonical file** (recommended) | ✅ Yes | ✅ Yes | ✅ Yes |
|
||||
| **B. Shell env var** (`~/.zshrc` / `~/.bashrc` / Windows env) | ✅ Yes | ✅ Yes (via env inheritance) | ✅ Yes |
|
||||
| **C. In-app paste only** | ❌ **No — session only** | ✅ Yes (this session) | ❌ No |
|
||||
## Setting via the app (recommended)
|
||||
|
||||
> **Why "session only" today:** as of v0.2.7, pasting a token into the app's Settings panel applies it to the current backend process, but doesn't write it to disk. The app prints:
|
||||
> > *"API keys and tokens are set for this session only. For persistence across restarts, set them as environment variables in your shell profile."*
|
||||
>
|
||||
> Once Phase 1 AUTH-03 ships (v0.3.x), pasting in Settings will save to the canonical HF file automatically. Until then, use Method A or B below.
|
||||
1. Open **Settings → API Keys**.
|
||||
2. Paste your HF token (get one from
|
||||
[huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) —
|
||||
the "read" scope is enough).
|
||||
3. Click **Save**. The token is encrypted at rest (Fernet symmetric AEAD,
|
||||
key derived per-install from machine-id) and also written to the
|
||||
canonical `huggingface_hub` token location so subprocess engines pick it
|
||||
up automatically.
|
||||
4. The row's `whoami` indicator flips green and the **Active** badge moves to
|
||||
"App".
|
||||
|
||||
---
|
||||
> **Known limitation (honest disclosure):** the encryption key is derived
|
||||
> per-install from the machine identifier. If you copy `omnivoice_data/`
|
||||
> across machines, the token row in `settings` will fail to decrypt on the
|
||||
> new machine — the resolver logs a warning and falls back to the env / CLI
|
||||
> source. Re-save the token on the new machine to re-encrypt with the
|
||||
> new install's key.
|
||||
|
||||
## Get your token
|
||||
## Setting via environment variable (power users)
|
||||
|
||||
1. Sign in at https://huggingface.co
|
||||
2. Visit https://huggingface.co/settings/tokens
|
||||
3. Click **"New token"** → **Type: Read** → give it a name like `omnivoice` → **Create**
|
||||
4. Copy the `hf_...` string
|
||||
|
||||
For pyannote diarization, also visit https://huggingface.co/pyannote/speaker-diarization-3.1 and click **"Agree and access repository"** — your token then has read access to the gated model.
|
||||
|
||||
---
|
||||
|
||||
## Method A — HF canonical file (recommended)
|
||||
|
||||
Picked up automatically by every HF library (`huggingface_hub`, `transformers`, `diffusers`, OmniVoice's backend, Tauri sidecar).
|
||||
|
||||
### macOS / Linux
|
||||
If you launch OmniVoice from a terminal or CI and prefer env-var management,
|
||||
export `HF_TOKEN` from your shell's startup file:
|
||||
|
||||
```bash
|
||||
pip install --user huggingface_hub # one-time, if not already installed
|
||||
huggingface-cli login # paste your hf_... token when prompted
|
||||
# macOS (zsh — default since 10.15)
|
||||
echo 'export HF_TOKEN=hf_yourtokenhere' >> ~/.zshrc && source ~/.zshrc
|
||||
|
||||
# Linux (bash)
|
||||
echo 'export HF_TOKEN=hf_yourtokenhere' >> ~/.bashrc && source ~/.bashrc
|
||||
```
|
||||
|
||||
This writes the token to `~/.cache/huggingface/token` with mode `0600`.
|
||||
|
||||
### Windows (PowerShell)
|
||||
**Windows PowerShell** — write to user-scope environment:
|
||||
|
||||
```powershell
|
||||
pip install --user huggingface_hub
|
||||
[Environment]::SetEnvironmentVariable("HF_TOKEN","hf_yourtokenhere","User")
|
||||
```
|
||||
|
||||
That persists for new shells. Close and reopen PowerShell or your terminal
|
||||
to see it.
|
||||
|
||||
> **Don't use `setx`.** `setx HF_TOKEN "hf_..."` writes the variable but
|
||||
> *doesn't propagate to the current shell* — a common source of "I set it
|
||||
> but it's empty" bug reports. Use the in-app Settings → API Keys path or
|
||||
> the `[Environment]::SetEnvironmentVariable` one-liner above.
|
||||
|
||||
## Setting via `huggingface-cli`
|
||||
|
||||
If you already use the HuggingFace CLI:
|
||||
|
||||
```bash
|
||||
pip install --upgrade huggingface_hub
|
||||
huggingface-cli login
|
||||
# paste token at the prompt
|
||||
```
|
||||
|
||||
Token is written to `%USERPROFILE%\.cache\huggingface\token`.
|
||||
That writes to `~/.cache/huggingface/token`. OmniVoice reads via
|
||||
`huggingface_hub.get_token()` and picks it up automatically — you'll see the
|
||||
**HF CLI** row in **Settings → API Keys** flip to "set".
|
||||
|
||||
### Verify
|
||||
## Accepting model licenses
|
||||
|
||||
```bash
|
||||
huggingface-cli whoami
|
||||
# Expected: your-username
|
||||
```
|
||||
Some models need both a token *and* a license acceptance click before
|
||||
downloads work. Visit each page while signed in with the same HF account:
|
||||
|
||||
### Remove (if needed)
|
||||
- `pyannote/speaker-diarization-3.1` — required for diarization.
|
||||
See [docs/features/diarization.md](../features/diarization.md).
|
||||
- `pyannote/segmentation-3.0` — required transitively by the above.
|
||||
- `IndexTeam/IndexTTS-2` — required if you use IndexTTS for voice cloning.
|
||||
- `Supertone/supertonic-3` — required if you enable the Supertonic-3 engine.
|
||||
|
||||
```bash
|
||||
huggingface-cli logout
|
||||
# or just: rm ~/.cache/huggingface/token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Method B — Shell environment variable
|
||||
|
||||
Useful if you don't want to install the `huggingface_hub` CLI, or you want every shell session to advertise the token via `echo $HF_TOKEN`.
|
||||
|
||||
### macOS (zsh — default since 10.15)
|
||||
|
||||
```bash
|
||||
echo 'export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
### Linux (bash)
|
||||
|
||||
```bash
|
||||
echo 'export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
### Linux (zsh)
|
||||
|
||||
```bash
|
||||
echo 'export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
### Windows — PowerShell (user scope, persists across reboots)
|
||||
|
||||
```powershell
|
||||
[Environment]::SetEnvironmentVariable("HF_TOKEN","hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx","User")
|
||||
```
|
||||
|
||||
You must **open a new PowerShell window** for the change to take effect (the current one won't see it — Microsoft's documented gotcha for `SetEnvironmentVariable`).
|
||||
|
||||
### Windows — cmd.exe
|
||||
|
||||
```cmd
|
||||
setx HF_TOKEN "hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
Same gotcha: open a new `cmd.exe` window to see it. `setx` writes to the registry but doesn't update the running shell.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
echo $HF_TOKEN # macOS / Linux
|
||||
echo $env:HF_TOKEN # PowerShell
|
||||
echo %HF_TOKEN% # cmd.exe
|
||||
```
|
||||
|
||||
### Remove
|
||||
|
||||
Edit the file (`~/.zshrc`, `~/.bashrc`) and delete the `export HF_TOKEN=...` line, then restart your shell. On Windows, run `[Environment]::SetEnvironmentVariable("HF_TOKEN", $null, "User")` in PowerShell.
|
||||
|
||||
---
|
||||
|
||||
## When OmniVoice picks it up
|
||||
|
||||
OmniVoice's backend reads the token in this priority order:
|
||||
|
||||
1. **`$HF_TOKEN` environment variable** (Methods B and C — set on shell or process)
|
||||
2. **`~/.cache/huggingface/token` file** (Method A)
|
||||
3. **In-app Settings paste** (Method C — overrides for this session only)
|
||||
|
||||
If multiple are set, the highest-priority one wins for that process. Use `huggingface-cli whoami` to confirm what the HF libs see, and check OmniVoice's status panel (Settings → Models → "HF auth: ✓ / ✗") to confirm the backend picked it up.
|
||||
|
||||
---
|
||||
After clicking **"Agree and access repository"** on each page, restart any
|
||||
in-flight OmniVoice job (the gated check is cached for the lifetime of the
|
||||
process).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Diarization fails with "401 unauthorized" | Token isn't reaching the diarization process | Make sure you ran `source ~/.zshrc` (or opened a new shell) AFTER setting the var, and that OmniVoice was started from that shell. Tauri-launched processes inherit from the launch shell's env. |
|
||||
| `huggingface-cli whoami` says "Not logged in" | Token file isn't where the CLI looks | Check `ls -la ~/.cache/huggingface/token`. If missing, re-run `huggingface-cli login`. |
|
||||
| Pyannote diarization 403 even after login | You haven't accepted the model terms | Visit https://huggingface.co/pyannote/speaker-diarization-3.1 and click "Agree". |
|
||||
| Windows: token set with `setx` but still empty in PowerShell | `setx` doesn't update the current shell | Open a new PowerShell window. |
|
||||
| Token works in Terminal but not in the Tauri app | Tauri launched from Finder/Spotlight doesn't source `~/.zshrc` | Use Method A instead (it's read from the disk file, no shell required). Or launch OmniVoice from a Terminal with `open /Applications/OmniVoice\ Studio.app`. |
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- **Never commit your token to git.** Add `*.env` and `.env.local` to `.gitignore` (OmniVoice already does this).
|
||||
- **The canonical file (`~/.cache/huggingface/token`) is mode `0600`** — only your user can read it.
|
||||
- **Use a `Read`-only token** unless you specifically need write access. Read tokens can still download gated models.
|
||||
- **OmniVoice never sends your token to any third-party endpoint.** The token only goes from your machine → `huggingface.co` for downloads. See [PROJECT.md "Local-first guarantee"](/.planning/PROJECT.md#constraints) for the full constraint.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-05-17 — applies to OmniVoice v0.2.7+*
|
||||
- **HF 401 even though a token is set** — visit the model's HuggingFace page
|
||||
and accept the license (see above). The token is fine; the *license* gate
|
||||
is separate.
|
||||
- **Token row stays red after Save** — the `whoami` call failed. Check the
|
||||
token is valid at
|
||||
[huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)
|
||||
and has at least the "read" scope.
|
||||
- **Token didn't survive a reboot** — open **Settings → API Keys** and check
|
||||
the App row. If it's empty, the SQLite store may have been wiped — re-save.
|
||||
If it's set but the active source is "Env" or "HF CLI", that's the cascade
|
||||
working as intended (App is highest priority).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { AlertCircle, BookOpen, RefreshCw } from 'lucide-react';
|
||||
import { classifyError, openDocsFor } from '../utils/errorDocsMap';
|
||||
import './WaveformErrorBoundary.css';
|
||||
|
||||
export default class ErrorBoundary extends React.Component {
|
||||
@@ -20,6 +21,20 @@ export default class ErrorBoundary extends React.Component {
|
||||
|
||||
reset = () => this.setState({ error: null });
|
||||
|
||||
openDocs = async () => {
|
||||
const cls =
|
||||
this.state.error?.errorClass /* explicit hint from the thrower */ ||
|
||||
classifyError(this.state.error);
|
||||
try {
|
||||
await openDocsFor(cls);
|
||||
} catch (err) {
|
||||
// openExternal already falls back to window.open; swallow any
|
||||
// remaining failure so the error boundary itself never throws.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[ErrorBoundary] openDocsFor failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
@@ -35,12 +50,22 @@ export default class ErrorBoundary extends React.Component {
|
||||
Don't worry — the rest of the app still works. You can switch tabs, or try again below.
|
||||
</p>
|
||||
<pre className="errbnd-trace">{msg}</pre>
|
||||
<button
|
||||
onClick={this.reset}
|
||||
className="btn-primary errbnd-retry"
|
||||
>
|
||||
<RefreshCw size={12} /> Try again
|
||||
</button>
|
||||
<div className="errbnd-actions">
|
||||
<button
|
||||
onClick={this.reset}
|
||||
className="btn-primary errbnd-retry"
|
||||
>
|
||||
<RefreshCw size={12} /> Try again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.openDocs}
|
||||
className="btn-secondary errbnd-docs"
|
||||
title="Open the docs page for this error in your browser"
|
||||
>
|
||||
<BookOpen size={12} /> Open docs for this error
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
vi.mock('../utils/errorDocsMap', async () => {
|
||||
const actual = await vi.importActual('../utils/errorDocsMap');
|
||||
return {
|
||||
...actual,
|
||||
openDocsFor: vi.fn(async (_cls) => {}),
|
||||
};
|
||||
});
|
||||
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import { openDocsFor } from '../utils/errorDocsMap';
|
||||
|
||||
function Boom({ message = 'pkg_resources missing' }) {
|
||||
throw new Error(message);
|
||||
// eslint-disable-next-line no-unreachable
|
||||
return null;
|
||||
}
|
||||
|
||||
function Boom401() {
|
||||
const e = new Error('HfHubHTTPError: 401 Unauthorized');
|
||||
throw e;
|
||||
}
|
||||
|
||||
describe('ErrorBoundary deeplink button', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(openDocsFor).mockClear();
|
||||
// Suppress the noisy React error log that fires on a thrown render.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('renders the "Open docs for this error" button on failure', () => {
|
||||
render(
|
||||
<ErrorBoundary name="test">
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText(/open docs for this error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the docs button calls openDocsFor with the classified class', async () => {
|
||||
render(
|
||||
<ErrorBoundary name="test">
|
||||
<Boom message="ModuleNotFoundError: pkg_resources" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/open docs for this error/i));
|
||||
expect(openDocsFor).toHaveBeenCalledTimes(1);
|
||||
expect(openDocsFor).toHaveBeenCalledWith('PKG_RESOURCES_MISSING');
|
||||
});
|
||||
|
||||
it('maps a 401 HF error to HF_AUTH_FAILED', async () => {
|
||||
render(
|
||||
<ErrorBoundary name="test">
|
||||
<Boom401 />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/open docs for this error/i));
|
||||
expect(openDocsFor).toHaveBeenCalledWith('HF_AUTH_FAILED');
|
||||
});
|
||||
|
||||
it('still renders a docs button for unknown errors (default fallback)', () => {
|
||||
render(
|
||||
<ErrorBoundary name="test">
|
||||
<Boom message="something totally unrelated" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/open docs for this error/i));
|
||||
// classifyError returns null → openDocsFor still called → wrapper picks default
|
||||
expect(openDocsFor).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,21 @@
|
||||
max-height: 140px; overflow: auto; margin: 0 0 14px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.errbnd-retry {
|
||||
.errbnd-actions {
|
||||
display: flex; gap: 8px; justify-content: center; flex-wrap: wrap;
|
||||
}
|
||||
.errbnd-retry,
|
||||
.errbnd-docs {
|
||||
padding: 6px 14px; font-size: 0.78rem; font-weight: 500;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.errbnd-docs {
|
||||
background: transparent;
|
||||
color: var(--chrome-fg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
.errbnd-docs:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* Settings → API Keys panel — Wave 2 AUTH-03 UI. Styled to match the existing
|
||||
Credentials tab + LogsFooter palette (var(--chrome-*) tokens). */
|
||||
|
||||
.apikeys-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.apikeys-panel__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.apikeys-panel__intro {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
|
||||
.apikeys-panel__intro code {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--chrome-hover-bg);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.apikeys-panel__error {
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--chrome-severity-err) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, transparent);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-size: 0.78rem;
|
||||
color: var(--chrome-severity-err);
|
||||
}
|
||||
|
||||
.apikeys-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.apikeys-row {
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
padding: 12px 14px;
|
||||
background: var(--chrome-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.apikeys-row--active {
|
||||
border-left: 2px solid var(--chrome-accent, #8ec07c);
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 5%, var(--chrome-bg));
|
||||
}
|
||||
|
||||
.apikeys-row__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.apikeys-row__name {
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.apikeys-badge {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
|
||||
.apikeys-badge--active {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 25%, transparent);
|
||||
color: var(--chrome-accent, #8ec07c);
|
||||
}
|
||||
|
||||
.apikeys-row__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
|
||||
.apikeys-row__set,
|
||||
.apikeys-row__unset,
|
||||
.apikeys-row__whoami {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.apikeys-row__set { color: var(--chrome-severity-ok, #8ec07c); }
|
||||
.apikeys-row__unset { color: var(--chrome-severity-warn, #fabd2f); }
|
||||
.apikeys-row__whoami--ok { color: var(--chrome-severity-ok, #8ec07c); }
|
||||
.apikeys-row__whoami--bad { color: var(--chrome-severity-err); }
|
||||
|
||||
.apikeys-row__masked {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
background: var(--chrome-hover-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.apikeys-row__help {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.apikeys-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.apikeys-input {
|
||||
flex: 1 1 220px;
|
||||
min-width: 220px;
|
||||
padding: 6px 10px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.apikeys-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--chrome-accent, #83a598);
|
||||
}
|
||||
|
||||
.apikeys-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
border: 1px solid var(--chrome-border);
|
||||
background: var(--chrome-bg);
|
||||
color: var(--chrome-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.apikeys-btn:hover:not(:disabled) {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
|
||||
.apikeys-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.apikeys-btn--save {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #83a598) 25%, var(--chrome-bg));
|
||||
border-color: var(--chrome-accent, #83a598);
|
||||
}
|
||||
|
||||
.apikeys-btn--danger {
|
||||
color: var(--chrome-severity-err);
|
||||
border-color: color-mix(in srgb, var(--chrome-severity-err) 35%, var(--chrome-border));
|
||||
}
|
||||
|
||||
.apikeys-btn--ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.apikeys-panel__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.apikeys-clear-dialog {
|
||||
margin-top: 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, var(--chrome-border));
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, var(--chrome-severity-err) 6%, var(--chrome-bg));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.apikeys-clear-dialog p {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.apikeys-checkbox {
|
||||
font-size: 0.78rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.apikeys-clear-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Settings → API Keys panel (Wave 2 AUTH-03 UI half).
|
||||
*
|
||||
* Consumes the Wave 1 resolver state endpoint at
|
||||
* GET /api/settings/hf-token/state
|
||||
* POST /api/settings/hf-token
|
||||
* DELETE /api/settings/hf-token?also_clear_hf_cli={bool}
|
||||
*
|
||||
* Renders one row per source (App / Env / HF CLI) with set/unset indicator,
|
||||
* masked token preview, whoami username + green check on success, and an
|
||||
* "Active" badge on whichever row is currently serving the cascade.
|
||||
*
|
||||
* Threat T-02-02: the panel never displays the full token. The masked
|
||||
* value comes from the resolver state endpoint; the full token only
|
||||
* crosses the IPC boundary on Save (POST) and is cleared from local
|
||||
* state on success.
|
||||
*
|
||||
* Note: the GET + POST go through `apiJson` / `apiPost` from
|
||||
* `../../api/client` (the canonical base-URL site). The DELETE uses raw
|
||||
* fetch with the same `API` base so query params can be appended cleanly.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { CheckCircle2, KeyRound, RefreshCw, Save, Trash2, XCircle } from 'lucide-react';
|
||||
import { apiJson, apiPost, API } from '../../api/client';
|
||||
import './ApiKeysPanel.css';
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
app: 'OmniVoice (encrypted, recommended)',
|
||||
env: 'Environment variable',
|
||||
'hf-cli': 'HuggingFace CLI',
|
||||
};
|
||||
|
||||
const SOURCE_HELP = {
|
||||
app: 'Stored encrypted in OmniVoice\'s local SQLite store. Set or clear here.',
|
||||
env: 'Set via HF_TOKEN in your shell. Read-only from the UI.',
|
||||
'hf-cli': 'Written by `huggingface-cli login`. Read-only from the UI.',
|
||||
};
|
||||
|
||||
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 [state, setState] = useState(EMPTY_STATE);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clearOpen, setClearOpen] = useState(false);
|
||||
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 || 'Failed to load token state');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const onSave = async () => {
|
||||
const token = tokenInput.trim();
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost('/api/settings/hf-token', { token });
|
||||
setTokenInput('');
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to save token');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onClear = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const qs = alsoClearCli ? '?also_clear_hf_cli=true' : '';
|
||||
const url = `${API}/api/settings/hf-token${qs}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setClearOpen(false);
|
||||
setAlsoClearCli(false);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to clear token');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="apikeys-panel" aria-labelledby="apikeys-heading">
|
||||
<h3 id="apikeys-heading" className="apikeys-panel__title">
|
||||
<KeyRound size={14} /> HuggingFace token
|
||||
</h3>
|
||||
<p className="apikeys-panel__intro">
|
||||
OmniVoice walks three sources in priority order (App → Env → HF CLI).
|
||||
The first source with a token that survives a live <code>whoami</code> check
|
||||
is the <strong>Active</strong> source.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="apikeys-panel__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="apikeys-rows" role="table" aria-label="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="apikeys-row__head">
|
||||
<span className="apikeys-row__name">{SOURCE_LABELS[row.source]}</span>
|
||||
{isActive && (
|
||||
<span className="apikeys-badge apikeys-badge--active">Active</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="apikeys-row__meta">
|
||||
{row.set ? (
|
||||
<>
|
||||
<span className="apikeys-row__set" aria-label="set">
|
||||
<CheckCircle2 size={12} /> set
|
||||
</span>
|
||||
{row.masked && (
|
||||
<code className="apikeys-row__masked">{row.masked}</code>
|
||||
)}
|
||||
{row.whoami_ok ? (
|
||||
<span className="apikeys-row__whoami apikeys-row__whoami--ok">
|
||||
<CheckCircle2 size={12} /> {row.whoami_user || 'verified'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="apikeys-row__whoami apikeys-row__whoami--bad">
|
||||
<XCircle size={12} /> whoami failed
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="apikeys-row__unset">
|
||||
<XCircle size={12} /> not set
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="apikeys-row__help">{SOURCE_HELP[row.source]}</p>
|
||||
{row.source === 'app' && (
|
||||
<div className="apikeys-row__actions">
|
||||
<input
|
||||
type="password"
|
||||
className="apikeys-input"
|
||||
placeholder="hf_…"
|
||||
aria-label="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="apikeys-btn apikeys-btn--save"
|
||||
onClick={onSave}
|
||||
disabled={!tokenInput.trim() || saving}
|
||||
>
|
||||
<Save size={12} /> Save
|
||||
</button>
|
||||
{row.set && (
|
||||
<button
|
||||
type="button"
|
||||
className="apikeys-btn apikeys-btn--danger"
|
||||
onClick={() => setClearOpen(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Trash2 size={12} /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="apikeys-panel__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="apikeys-btn apikeys-btn--ghost"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
aria-label="Test now"
|
||||
title="Re-run whoami for every source"
|
||||
>
|
||||
<RefreshCw size={12} /> Test now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{clearOpen && (
|
||||
<div className="apikeys-clear-dialog" role="dialog" aria-label="Clear token">
|
||||
<p>Clear the App-source HuggingFace token?</p>
|
||||
<label className="apikeys-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alsoClearCli}
|
||||
onChange={(e) => setAlsoClearCli(e.target.checked)}
|
||||
/>{' '}
|
||||
Also clear <code>~/.cache/huggingface/token</code>
|
||||
</label>
|
||||
<div className="apikeys-clear-dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="apikeys-btn apikeys-btn--ghost"
|
||||
onClick={() => {
|
||||
setClearOpen(false);
|
||||
setAlsoClearCli(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="apikeys-btn apikeys-btn--danger"
|
||||
onClick={onClear}
|
||||
disabled={saving}
|
||||
>
|
||||
<Trash2 size={12} /> Clear token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const STATE_THREE_UNSET = {
|
||||
active: null,
|
||||
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 },
|
||||
],
|
||||
};
|
||||
|
||||
const STATE_APP_ACTIVE = {
|
||||
active: 'app',
|
||||
sources: [
|
||||
{ source: 'app', set: true, masked: 'hf_…abc', whoami_user: 'alice', whoami_ok: true },
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
|
||||
const STATE_ENV_ACTIVE = {
|
||||
active: 'env',
|
||||
sources: [
|
||||
{ source: 'app', set: false, masked: null, whoami_user: null, whoami_ok: false },
|
||||
{ source: 'env', set: true, masked: 'hf_…xyz', whoami_user: 'bob', whoami_ok: true },
|
||||
{ source: 'hf-cli', set: false, masked: null, whoami_user: null, whoami_ok: false },
|
||||
],
|
||||
};
|
||||
|
||||
function mockFetchOnce(payload, status = 200) {
|
||||
return vi.fn().mockResolvedValueOnce({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
text: async () => JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function mockFetchSequence(...responses) {
|
||||
const fn = vi.fn();
|
||||
for (const r of responses) {
|
||||
fn.mockResolvedValueOnce({
|
||||
ok: r.status >= 200 && r.status < 300,
|
||||
status: r.status,
|
||||
json: async () => r.body,
|
||||
text: async () => JSON.stringify(r.body),
|
||||
});
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
import ApiKeysPanel from './ApiKeysPanel';
|
||||
|
||||
describe('ApiKeysPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders 3 source rows after mount', async () => {
|
||||
global.fetch = mockFetchOnce(STATE_THREE_UNSET);
|
||||
const { container } = render(<ApiKeysPanel />);
|
||||
await waitFor(() => {
|
||||
const rows = container.querySelectorAll('.apikeys-row');
|
||||
expect(rows.length).toBe(3);
|
||||
expect(container.querySelector('[data-source="app"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-source="env"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-source="hf-cli"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the Active badge on the row matching state.active', async () => {
|
||||
global.fetch = mockFetchOnce(STATE_APP_ACTIVE);
|
||||
const { container } = render(<ApiKeysPanel />);
|
||||
await waitFor(() => {
|
||||
const appRow = container.querySelector('[data-source="app"]');
|
||||
expect(appRow).not.toBeNull();
|
||||
expect(appRow.classList.contains('apikeys-row--active')).toBe(true);
|
||||
const badge = appRow.querySelector('.apikeys-badge--active');
|
||||
expect(badge?.textContent).toMatch(/active/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('moves the Active badge when the env source is active', async () => {
|
||||
global.fetch = mockFetchOnce(STATE_ENV_ACTIVE);
|
||||
const { container } = render(<ApiKeysPanel />);
|
||||
await waitFor(() => {
|
||||
const envRow = container.querySelector('[data-source="env"]');
|
||||
expect(envRow?.classList.contains('apikeys-row--active')).toBe(true);
|
||||
const appRow = container.querySelector('[data-source="app"]');
|
||||
expect(appRow?.classList.contains('apikeys-row--active')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('Save button POSTs the entered token and refetches state', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ status: 200, body: STATE_THREE_UNSET }, // initial GET
|
||||
{ status: 200, body: STATE_APP_ACTIVE }, // POST returns updated state
|
||||
{ status: 200, body: STATE_APP_ACTIVE }, // GET after save
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
|
||||
render(<ApiKeysPanel />);
|
||||
await waitFor(() => screen.getByPlaceholderText(/hf_/));
|
||||
|
||||
const input = screen.getByPlaceholderText(/hf_/);
|
||||
fireEvent.change(input, { target: { value: 'hf_newtoken123' } });
|
||||
const saveBtn = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.mock.calls;
|
||||
// Find the POST call
|
||||
const postCall = calls.find(([_url, opts]) => opts && opts.method === 'POST');
|
||||
expect(postCall).toBeTruthy();
|
||||
const [url, init] = postCall;
|
||||
expect(url).toMatch(/\/api\/settings\/hf-token$/);
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body).toEqual({ token: 'hf_newtoken123' });
|
||||
});
|
||||
});
|
||||
|
||||
it('Clear button shows confirmation dialog and DELETEs on confirm', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ status: 200, body: STATE_APP_ACTIVE }, // initial GET
|
||||
{ status: 200, body: STATE_THREE_UNSET }, // DELETE response
|
||||
{ status: 200, body: STATE_THREE_UNSET }, // refetch GET
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
|
||||
render(<ApiKeysPanel />);
|
||||
await waitFor(() => screen.getByPlaceholderText(/hf_/));
|
||||
|
||||
const clearBtn = screen.getByRole('button', { name: /^clear$/i });
|
||||
fireEvent.click(clearBtn);
|
||||
|
||||
// Dialog appears
|
||||
expect(screen.getByText(/Clear the App-source HuggingFace token/)).toBeInTheDocument();
|
||||
const confirmBtn = screen.getByRole('button', { name: /clear token/i });
|
||||
fireEvent.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.mock.calls;
|
||||
const del = calls.find(([_u, opts]) => opts && opts.method === 'DELETE');
|
||||
expect(del).toBeTruthy();
|
||||
expect(del[0]).toMatch(/\/api\/settings\/hf-token/);
|
||||
// also_clear_hf_cli default is false → no query string
|
||||
expect(del[0]).not.toMatch(/also_clear_hf_cli=true/);
|
||||
});
|
||||
});
|
||||
|
||||
it('"Test now" button refetches state', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ status: 200, body: STATE_THREE_UNSET },
|
||||
{ status: 200, body: STATE_THREE_UNSET },
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
|
||||
render(<ApiKeysPanel />);
|
||||
await waitFor(() => screen.getByPlaceholderText(/hf_/));
|
||||
const testBtn = screen.getByRole('button', { name: /test now/i });
|
||||
fireEvent.click(testBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Settings → Performance panel (Wave 2 INST-12). Matches the API Keys
|
||||
panel palette for visual consistency in the Credentials tab. */
|
||||
|
||||
.perfpanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.perfpanel__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.perfpanel__error {
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--chrome-severity-err) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, transparent);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-size: 0.78rem;
|
||||
color: var(--chrome-severity-err);
|
||||
}
|
||||
|
||||
.perfpanel__row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.perfpanel__row:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.perfpanel__checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.perfpanel__badge {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg-muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.perfpanel__help {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
|
||||
.perfpanel__help code {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--chrome-hover-bg);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.perfpanel__help a {
|
||||
color: var(--chrome-accent, #83a598);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.perfpanel__help a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Settings → Performance panel (Wave 2 INST-12 UI half).
|
||||
*
|
||||
* Toggles the `Disable torch.compile (Windows)` setting that backend
|
||||
* engine launchers read via `services.engine_env.build_engine_env()`.
|
||||
*
|
||||
* The toggle is disabled (with an explainer tooltip) on non-Windows
|
||||
* platforms — torch.compile OOMs the same Triton kernel cache
|
||||
* differently on macOS / Linux, so toggling it there would just slow
|
||||
* the engine for no gain (issue #65).
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/settings/perf/torch-compile-disabled
|
||||
* → {"enabled": bool, "platform": "darwin"|"linux"|"win32"}
|
||||
* PUT /api/settings/perf/torch-compile-disabled
|
||||
* body {"enabled": bool} (loopback-only)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import './PerformancePanel.css';
|
||||
|
||||
export default function PerformancePanel() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [platform, setPlatform] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiJson('/api/settings/perf/torch-compile-disabled');
|
||||
setEnabled(Boolean(data?.enabled));
|
||||
setPlatform(data?.platform ?? null);
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to load performance settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const isWindows = platform === 'win32';
|
||||
const tooltip = isWindows
|
||||
? 'Sets TORCH_COMPILE_DISABLE=1 on engine subprocesses to dodge the Windows torch.compile OOM (#65).'
|
||||
: 'This setting only affects Windows; on macOS/Linux torch.compile is not the OOM source.';
|
||||
|
||||
const onToggle = async (e) => {
|
||||
const next = e.target.checked;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/perf/torch-compile-disabled', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setEnabled(Boolean(body?.enabled ?? next));
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to save setting');
|
||||
// Re-sync on failure so the UI doesn't show a stale state
|
||||
refresh();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="perfpanel" aria-labelledby="perfpanel-heading">
|
||||
<h3 id="perfpanel-heading" className="perfpanel__title">
|
||||
<Cpu size={14} /> Performance
|
||||
</h3>
|
||||
|
||||
{error && (
|
||||
<div className="perfpanel__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="perfpanel__row" title={tooltip}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="perfpanel__checkbox"
|
||||
checked={enabled}
|
||||
onChange={onToggle}
|
||||
disabled={!isWindows || saving || loading}
|
||||
data-testid="torch-compile-toggle"
|
||||
/>
|
||||
<span className="perfpanel__label">Disable torch.compile (Windows)</span>
|
||||
{!isWindows && (
|
||||
<span className="perfpanel__badge">{platform === null ? '…' : 'not applicable'}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<p className="perfpanel__help">
|
||||
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 <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.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
function mockFetchSequence(...responses) {
|
||||
const fn = vi.fn();
|
||||
for (const r of responses) {
|
||||
fn.mockResolvedValueOnce({
|
||||
ok: r.status >= 200 && r.status < 300,
|
||||
status: r.status,
|
||||
json: async () => r.body,
|
||||
text: async () => JSON.stringify(r.body),
|
||||
});
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
import PerformancePanel from './PerformancePanel';
|
||||
|
||||
describe('PerformancePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the toggle unchecked from GET state (Windows)', async () => {
|
||||
global.fetch = mockFetchSequence({
|
||||
status: 200,
|
||||
body: { enabled: false, platform: 'win32' },
|
||||
});
|
||||
render(<PerformancePanel />);
|
||||
await waitFor(() => {
|
||||
const toggle = screen.getByTestId('torch-compile-toggle');
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(toggle).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggle click PUTs the new state', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ status: 200, body: { enabled: false, platform: 'win32' } }, // initial GET
|
||||
{ status: 200, body: { enabled: true, platform: 'win32' } }, // PUT
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
|
||||
render(<PerformancePanel />);
|
||||
await waitFor(() => screen.getByTestId('torch-compile-toggle'));
|
||||
const toggle = screen.getByTestId('torch-compile-toggle');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
const put = fetchMock.mock.calls.find(
|
||||
([_u, opts]) => opts && opts.method === 'PUT',
|
||||
);
|
||||
expect(put).toBeTruthy();
|
||||
expect(put[0]).toMatch(/\/api\/settings\/perf\/torch-compile-disabled$/);
|
||||
expect(JSON.parse(put[1].body)).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('renders disabled with badge on non-Windows platforms (darwin)', async () => {
|
||||
global.fetch = mockFetchSequence({
|
||||
status: 200,
|
||||
body: { enabled: false, platform: 'darwin' },
|
||||
});
|
||||
render(<PerformancePanel />);
|
||||
await waitFor(() => {
|
||||
const toggle = screen.getByTestId('torch-compile-toggle');
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByText(/not applicable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders disabled on linux platform', async () => {
|
||||
global.fetch = mockFetchSequence({
|
||||
status: 200,
|
||||
body: { enabled: false, platform: 'linux' },
|
||||
});
|
||||
render(<PerformancePanel />);
|
||||
await waitFor(() => {
|
||||
const toggle = screen.getByTestId('torch-compile-toggle');
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders pre-enabled when backend reports enabled=true', async () => {
|
||||
global.fetch = mockFetchSequence({
|
||||
status: 200,
|
||||
body: { enabled: true, platform: 'win32' },
|
||||
});
|
||||
render(<PerformancePanel />);
|
||||
await waitFor(() => {
|
||||
const toggle = screen.getByTestId('torch-compile-toggle');
|
||||
expect(toggle).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import { setupDownloadStreamUrl } from '../api/setup';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { Tabs, Segmented, Button, Badge, Panel, Table, Progress } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
import ApiKeysPanel from '../components/settings/ApiKeysPanel';
|
||||
import PerformancePanel from '../components/settings/PerformancePanel';
|
||||
import './Settings.css';
|
||||
|
||||
const TABS = [
|
||||
@@ -1503,12 +1505,22 @@ function CredentialsTab({ info }) {
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<h2><KeyRound size={16} color="#fe8019" /> Credentials</h2>
|
||||
|
||||
{/* Wave 2 AUTH-03 panel — 3-source cascade with Active badge,
|
||||
encrypted-at-rest App-source storage, and live whoami status. */}
|
||||
<ApiKeysPanel />
|
||||
|
||||
{/* Wave 2 INST-12 panel — Windows torch.compile OOM workaround
|
||||
(#65). Toggle is rendered disabled on macOS/Linux with an
|
||||
explainer; backend ignores the flag on non-Windows. */}
|
||||
<PerformancePanel />
|
||||
|
||||
<p className="settings-prose">
|
||||
API keys and tokens are set <strong>for this session only</strong>. For
|
||||
persistence across restarts, set them as environment variables in your
|
||||
shell profile.
|
||||
Other API keys and tokens are set <strong>for this session only</strong>.
|
||||
For persistence across restarts, set them as environment variables in
|
||||
your shell profile.
|
||||
</p>
|
||||
{CREDENTIAL_FIELDS.map(field => (
|
||||
{CREDENTIAL_FIELDS.filter(f => f.key !== 'HF_TOKEN').map(field => (
|
||||
<div key={field.key} className="settings-credential">
|
||||
<div className="settings-credential__header">
|
||||
<label className="settings-credential__label">{field.label}</label>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../api/external', () => ({
|
||||
openExternal: vi.fn(async (_url: string) => {}),
|
||||
}));
|
||||
|
||||
import { openExternal } from '../api/external';
|
||||
import {
|
||||
classifyError,
|
||||
ERROR_DOCS,
|
||||
ERROR_CLASS_KEYS,
|
||||
DEFAULT_DOCS,
|
||||
openDocsFor,
|
||||
urlFor,
|
||||
} from './errorDocsMap';
|
||||
|
||||
describe('errorDocsMap', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(openExternal).mockClear();
|
||||
});
|
||||
|
||||
it('openDocsFor known class calls openExternal with the right URL', async () => {
|
||||
await openDocsFor('HF_AUTH_FAILED');
|
||||
expect(openExternal).toHaveBeenCalledTimes(1);
|
||||
expect(openExternal).toHaveBeenCalledWith(ERROR_DOCS.HF_AUTH_FAILED);
|
||||
});
|
||||
|
||||
it('openDocsFor unknown class falls back to default', async () => {
|
||||
await openDocsFor('BOGUS_NOT_A_REAL_CLASS');
|
||||
expect(openExternal).toHaveBeenCalledWith(DEFAULT_DOCS);
|
||||
});
|
||||
|
||||
it('openDocsFor null falls back to default', async () => {
|
||||
await openDocsFor(null);
|
||||
expect(openExternal).toHaveBeenCalledWith(DEFAULT_DOCS);
|
||||
});
|
||||
|
||||
// Sentinel test — locks the 4-class taxonomy in lockstep with the
|
||||
// Python map (backend/core/error_docs_map.py). Adding a 5th class is
|
||||
// a contract change; update both sides + this list.
|
||||
it('keys match the locked taxonomy (mirror of Python map)', () => {
|
||||
expect(Object.keys(ERROR_DOCS).sort()).toEqual([...ERROR_CLASS_KEYS].sort());
|
||||
expect(Object.keys(ERROR_DOCS).sort()).toEqual(
|
||||
[
|
||||
'APPIMAGE_WEBKIT_WHITESCREEN',
|
||||
'GATEKEEPER_QUARANTINE',
|
||||
'HF_AUTH_FAILED',
|
||||
'PKG_RESOURCES_MISSING',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('every URL resolves under the project repo blob', () => {
|
||||
const base = 'https://github.com/debpalash/OmniVoice-Studio/blob/main';
|
||||
for (const [key, url] of Object.entries(ERROR_DOCS)) {
|
||||
expect(url.startsWith(base), `${key} not under ${base}: ${url}`).toBe(true);
|
||||
}
|
||||
expect(DEFAULT_DOCS.startsWith(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('classifyError maps pkg_resources to PKG_RESOURCES_MISSING', () => {
|
||||
expect(classifyError(new Error('ModuleNotFoundError: No module named pkg_resources'))).toBe(
|
||||
'PKG_RESOURCES_MISSING',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifyError maps 401 / HfHubHTTPError to HF_AUTH_FAILED', () => {
|
||||
expect(classifyError(new Error('HfHubHTTPError: 401 Unauthorized'))).toBe('HF_AUTH_FAILED');
|
||||
expect(classifyError(new Error('Got 401 from HuggingFace'))).toBe('HF_AUTH_FAILED');
|
||||
});
|
||||
|
||||
it('classifyError maps WebKit / white screen to APPIMAGE_WEBKIT_WHITESCREEN', () => {
|
||||
expect(classifyError(new Error('webkit compositing failed'))).toBe(
|
||||
'APPIMAGE_WEBKIT_WHITESCREEN',
|
||||
);
|
||||
expect(classifyError(new Error('white screen on Fedora'))).toBe(
|
||||
'APPIMAGE_WEBKIT_WHITESCREEN',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifyError maps quarantine / Gatekeeper to GATEKEEPER_QUARANTINE', () => {
|
||||
expect(classifyError(new Error('com.apple.quarantine flag'))).toBe('GATEKEEPER_QUARANTINE');
|
||||
expect(classifyError(new Error('Gatekeeper blocked the launch'))).toBe(
|
||||
'GATEKEEPER_QUARANTINE',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifyError returns null on unknown messages', () => {
|
||||
expect(classifyError(new Error('Something totally unrelated'))).toBeNull();
|
||||
});
|
||||
|
||||
it('urlFor null returns DEFAULT_DOCS', () => {
|
||||
expect(urlFor(null)).toBe(DEFAULT_DOCS);
|
||||
});
|
||||
|
||||
it('urlFor a known class returns that URL', () => {
|
||||
expect(urlFor('GATEKEEPER_QUARANTINE')).toBe(ERROR_DOCS.GATEKEEPER_QUARANTINE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// MIRROR OF backend/core/error_docs_map.py — keep in sync.
|
||||
// The Python test_keys_match_python_map / test_keys_match_taxonomy guards
|
||||
// the 4-class taxonomy on the backend; the `_KEYS` array below is the
|
||||
// TS-side anchor (the keys-sync test imports it and asserts equality).
|
||||
//
|
||||
// This `BASE` constant is the SECOND hardcoded URL drift site: the
|
||||
// canonical Python-side resolver is `backend/core/links.py`
|
||||
// (`PROJECT_REPO_BLOB_MAIN`). The TS half runs in the browser and can't
|
||||
// read `pyproject.toml`, so it gets a hand-maintained mirror. Centralising
|
||||
// the URL on the TS side is a v0.4 concern — the milestone accepts the
|
||||
// drift risk and relies on the keys-sync test + threat-model T-02-01
|
||||
// to bound the blast radius.
|
||||
|
||||
import { openExternal } from '../api/external';
|
||||
|
||||
const BASE = 'https://github.com/debpalash/OmniVoice-Studio/blob/main';
|
||||
|
||||
export const ERROR_DOCS: Record<string, string> = {
|
||||
GATEKEEPER_QUARANTINE: `${BASE}/docs/install/macos.md#gatekeeper-quarantine`,
|
||||
APPIMAGE_WEBKIT_WHITESCREEN: `${BASE}/docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404`,
|
||||
PKG_RESOURCES_MISSING: `${BASE}/docs/install/troubleshooting.md#pkg_resources-missing`,
|
||||
HF_AUTH_FAILED: `${BASE}/docs/setup/huggingface-token.md`,
|
||||
};
|
||||
|
||||
export const DEFAULT_DOCS = `${BASE}/docs/install/troubleshooting.md`;
|
||||
|
||||
// Locked taxonomy keys — Phase 5 bug reporter consumes this exact set.
|
||||
// Adding a 5th class is a contract change; update the Python map at the
|
||||
// same time (`backend/core/error_docs_map.py`).
|
||||
export const ERROR_CLASS_KEYS = [
|
||||
'GATEKEEPER_QUARANTINE',
|
||||
'APPIMAGE_WEBKIT_WHITESCREEN',
|
||||
'PKG_RESOURCES_MISSING',
|
||||
'HF_AUTH_FAILED',
|
||||
] as const;
|
||||
|
||||
export type ErrorClass = (typeof ERROR_CLASS_KEYS)[number];
|
||||
|
||||
/**
|
||||
* Heuristic error message → ErrorClass classifier. ErrorBoundary uses this
|
||||
* when the thrown Error doesn't carry an explicit `errorClass` property.
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorClass | null {
|
||||
const message =
|
||||
(error as { message?: string } | null | undefined)?.message ?? String(error ?? '');
|
||||
const lower = message.toLowerCase();
|
||||
if (/pkg_resources/.test(lower)) return 'PKG_RESOURCES_MISSING';
|
||||
if (/\b401\b/.test(lower) || /hfhub|hfhubhttp/.test(lower) || /unauthorized/.test(lower)) {
|
||||
return 'HF_AUTH_FAILED';
|
||||
}
|
||||
if (/webkit/.test(lower) || /white\s*screen/.test(lower)) {
|
||||
return 'APPIMAGE_WEBKIT_WHITESCREEN';
|
||||
}
|
||||
if (/quarantine/.test(lower) || /gatekeeper/.test(lower)) return 'GATEKEEPER_QUARANTINE';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function urlFor(errorClass: ErrorClass | null | undefined): string {
|
||||
if (!errorClass) return DEFAULT_DOCS;
|
||||
return ERROR_DOCS[errorClass] ?? DEFAULT_DOCS;
|
||||
}
|
||||
|
||||
/** Open the docs URL for `errorClass` in the user's default browser. */
|
||||
export async function openDocsFor(
|
||||
errorClass: ErrorClass | string | null | undefined,
|
||||
): Promise<void> {
|
||||
const url = ERROR_DOCS[errorClass as string] ?? DEFAULT_DOCS;
|
||||
await openExternal(url);
|
||||
}
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""validate-install-docs.py — Phase 1 INST-06 docs-drift CI gate.
|
||||
|
||||
Extract every fenced code block tagged with an HTML comment marker
|
||||
`<!-- validate -->` from `docs/install/*.md` and assert each line of the
|
||||
block appears (after normalisation) in `scripts/desktop-prod.sh`. The script
|
||||
exits 1 on the first drift and prints the offending file + line on stderr so
|
||||
CI logs lead the contributor straight to the fix.
|
||||
|
||||
Markers:
|
||||
<!-- validate --> — gate the next fenced code block
|
||||
<!-- validate: skip --> — opt-out: block exists for human readability only
|
||||
|
||||
Normalisation (per RESEARCH Pitfall #4):
|
||||
- rstrip trailing whitespace
|
||||
- normalise CRLF → LF
|
||||
- strip `$ ` and `>>> ` REPL/prompt prefixes
|
||||
- skip blank lines + lines that are only `#` comments
|
||||
|
||||
The validator is intentionally a one-way check: every validated docs line
|
||||
must appear in the install script, but the script may contain extra setup
|
||||
the docs don't surface (cleanup, log dirs, etc.). That asymmetry catches
|
||||
"docs claim a command that the install path doesn't run" without forcing
|
||||
docs to repeat every line of the install script.
|
||||
|
||||
Public entry point: `main(root: Path | None = None) -> int`
|
||||
Returns 0 on success, 1 on drift. Importable from unit tests so we can
|
||||
exercise the validator against tmp-path fixtures (per checker B-5 — the
|
||||
validator itself is regression-tested).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
# Match the `<!-- validate -->` (or `<!-- validate: skip -->`) marker on its
|
||||
# own line, followed by an optional blank line, followed by a fenced block.
|
||||
_MARKER_RE = re.compile(
|
||||
r"<!--\s*validate(?:\s*:\s*(?P<modifier>skip))?\s*-->",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FENCE_OPEN_RE = re.compile(r"^```([A-Za-z0-9_+\-]*)\s*$")
|
||||
_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
|
||||
|
||||
_PROMPT_PREFIXES = ("$ ", ">>> ")
|
||||
|
||||
|
||||
def _normalise_line(line: str) -> str:
|
||||
"""Strip prompt prefixes + trailing whitespace + CRs. Returns '' for
|
||||
blank and comment-only lines (the caller treats '' as 'skip')."""
|
||||
# CRLF → LF was done at file read time; rstrip handles trailing CR too.
|
||||
s = line.rstrip("\r\n").rstrip()
|
||||
if not s:
|
||||
return ""
|
||||
if s.lstrip().startswith("#"):
|
||||
# Skip pure-comment lines — they're docs scaffolding, not commands.
|
||||
return ""
|
||||
for prefix in _PROMPT_PREFIXES:
|
||||
if s.lstrip().startswith(prefix):
|
||||
s = s.replace(prefix, "", 1)
|
||||
break
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _normalise_script(text: str) -> set[str]:
|
||||
"""Return the set of normalised lines from the install script.
|
||||
|
||||
The script has shebangs, env exports, function defs, etc. — we
|
||||
intentionally compare against the *entire* normalised contents (minus
|
||||
blanks/comments) so docs may pull any line that survives the install
|
||||
flow."""
|
||||
out: set[str] = set()
|
||||
for raw in text.splitlines():
|
||||
norm = _normalise_line(raw)
|
||||
if norm:
|
||||
out.add(norm)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_validated_blocks(md_text: str) -> list[tuple[int, str, bool]]:
|
||||
"""Return a list of (start_line_no_1_indexed, body, skip_flag) tuples for
|
||||
every `<!-- validate -->` block found in the markdown."""
|
||||
lines = md_text.splitlines()
|
||||
blocks: list[tuple[int, str, bool]] = []
|
||||
i = 0
|
||||
pending_marker: tuple[int, bool] | None = None
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
m = _MARKER_RE.search(line)
|
||||
if m:
|
||||
pending_marker = (i + 1, (m.group("modifier") == "skip"))
|
||||
i += 1
|
||||
continue
|
||||
if pending_marker is not None and _FENCE_OPEN_RE.match(line):
|
||||
# Consume until matching close fence.
|
||||
block_lines: list[str] = []
|
||||
block_start = pending_marker[0]
|
||||
skip = pending_marker[1]
|
||||
pending_marker = None
|
||||
i += 1
|
||||
while i < len(lines) and not _FENCE_CLOSE_RE.match(lines[i]):
|
||||
block_lines.append(lines[i])
|
||||
i += 1
|
||||
i += 1 # skip the closing fence
|
||||
blocks.append((block_start, "\n".join(block_lines), skip))
|
||||
continue
|
||||
# Marker followed by something other than a fence — drop it.
|
||||
if pending_marker is not None and line.strip() and not _FENCE_OPEN_RE.match(line):
|
||||
pending_marker = None
|
||||
i += 1
|
||||
return blocks
|
||||
|
||||
|
||||
def _iter_docs(root: Path) -> Iterable[Path]:
|
||||
docs_dir = root / "docs" / "install"
|
||||
if not docs_dir.exists():
|
||||
return []
|
||||
return sorted(docs_dir.glob("*.md"))
|
||||
|
||||
|
||||
def main(root: Path | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Repo root to scan (defaults to the repo containing this script).",
|
||||
)
|
||||
# When `main()` is called programmatically (unit tests), we still want
|
||||
# argparse to work — pass an empty argv so it doesn't accidentally see
|
||||
# pytest's command-line args.
|
||||
if root is not None:
|
||||
args = parser.parse_args([])
|
||||
args.root = root
|
||||
else:
|
||||
args = parser.parse_args()
|
||||
if args.root is None:
|
||||
args.root = Path(__file__).resolve().parent.parent
|
||||
|
||||
script_path = args.root / "scripts" / "desktop-prod.sh"
|
||||
if not script_path.exists():
|
||||
print(
|
||||
f"validate-install-docs: missing {script_path}; nothing to validate against",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
canonical = _normalise_script(script_path.read_text(encoding="utf-8"))
|
||||
|
||||
errors: list[str] = []
|
||||
validated = 0
|
||||
for md_path in _iter_docs(args.root):
|
||||
md_text = md_path.read_text(encoding="utf-8")
|
||||
for start_line, body, skip in _extract_validated_blocks(md_text):
|
||||
validated += 1
|
||||
if skip:
|
||||
continue
|
||||
for offset, raw in enumerate(body.splitlines(), start=0):
|
||||
norm = _normalise_line(raw)
|
||||
if not norm:
|
||||
continue
|
||||
if norm not in canonical:
|
||||
errors.append(
|
||||
f"{md_path.relative_to(args.root)}:{start_line + 1 + offset}: "
|
||||
f"docs line not present in scripts/desktop-prod.sh: {norm!r}"
|
||||
)
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(e, file=sys.stderr)
|
||||
print(
|
||||
f"\nvalidate-install-docs: {len(errors)} drift(s) in {validated} validated block(s).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"OK — {validated} install docs block(s) validated against {script_path.name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests for backend/core/error_docs_map.py — error → docs URL taxonomy.
|
||||
|
||||
The 4-class taxonomy is the contract Phase 5's bug reporter consumes and
|
||||
the TS-side `frontend/src/utils/errorDocsMap.ts` mirrors. These tests pin
|
||||
both the keys and that every URL points back to the project repo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_known_class_returns_url():
|
||||
from core import error_docs_map
|
||||
url = error_docs_map.lookup("GATEKEEPER_QUARANTINE")
|
||||
assert "docs/install/macos.md#gatekeeper-quarantine" in url
|
||||
|
||||
|
||||
def test_unknown_class_returns_default():
|
||||
from core import error_docs_map
|
||||
assert error_docs_map.lookup("NEVER_HEARD_OF_IT") == error_docs_map.DEFAULT_DOCS
|
||||
|
||||
|
||||
def test_none_returns_default():
|
||||
from core import error_docs_map
|
||||
assert error_docs_map.lookup(None) == error_docs_map.DEFAULT_DOCS
|
||||
|
||||
|
||||
def test_all_urls_resolve_to_repo():
|
||||
from core import error_docs_map
|
||||
from core import links
|
||||
base = links.PROJECT_REPO_BLOB_MAIN
|
||||
for cls, url in error_docs_map.ERROR_DOCS.items():
|
||||
assert url.startswith(base), f"{cls} URL not in repo blob: {url}"
|
||||
assert error_docs_map.DEFAULT_DOCS.startswith(base)
|
||||
|
||||
|
||||
def test_all_keys_match_taxonomy():
|
||||
"""The 4-class taxonomy is locked here. Adding a 5th class is a contract
|
||||
change — bump this set + the TS mirror's keys-sync test in lockstep."""
|
||||
from core import error_docs_map
|
||||
expected = {
|
||||
"GATEKEEPER_QUARANTINE",
|
||||
"APPIMAGE_WEBKIT_WHITESCREEN",
|
||||
"PKG_RESOURCES_MISSING",
|
||||
"HF_AUTH_FAILED",
|
||||
}
|
||||
assert set(error_docs_map.ERROR_DOCS.keys()) == expected
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for backend/core/links.py — project repo URL resolver.
|
||||
|
||||
The module owns the single source of truth for the GitHub repo URL used by
|
||||
error → docs deeplinks (and, in Phase 5, by the prefilled bug-report URL).
|
||||
The 4 tests below pin the resolution order:
|
||||
|
||||
1. Tauri config endpoint wins when present.
|
||||
2. Falls back to pyproject's Repository URL when Tauri is unreadable.
|
||||
3. The derived `BLOB_MAIN` constant is always `<URL>/blob/main`.
|
||||
4. The URL always starts with `https://github.com/`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def _fresh_links_module():
|
||||
"""Drop any cached `core.links` so the next import re-runs `_resolve()`."""
|
||||
for mod in list(sys.modules):
|
||||
if mod == "core.links":
|
||||
del sys.modules[mod]
|
||||
import core.links as links # noqa: WPS433 — needed for re-import
|
||||
return importlib.reload(links)
|
||||
|
||||
|
||||
def test_project_repo_url_is_set():
|
||||
links = _fresh_links_module()
|
||||
assert isinstance(links.PROJECT_REPO_URL, str)
|
||||
assert links.PROJECT_REPO_URL.startswith("https://github.com/")
|
||||
|
||||
|
||||
def test_project_repo_blob_main_derives_from_url():
|
||||
links = _fresh_links_module()
|
||||
assert links.PROJECT_REPO_BLOB_MAIN == links.PROJECT_REPO_URL + "/blob/main"
|
||||
|
||||
|
||||
def test_prefers_tauri_config_when_present(monkeypatch, tmp_path):
|
||||
"""With a fake `tauri.conf.json` containing the desktop fork URL in the
|
||||
updater endpoint, `PROJECT_REPO_URL` resolves to that fork (NOT the
|
||||
pyproject upstream)."""
|
||||
fake_conf = {
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://github.com/debpalash/OmniVoice-Studio/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
# Reload the module first to pick up the original constants, then exercise
|
||||
# the resolver helpers directly.
|
||||
links = _fresh_links_module()
|
||||
monkeypatch.setattr(links, "_TAURI_CONF", tmp_path / "tauri.conf.json")
|
||||
import json
|
||||
(tmp_path / "tauri.conf.json").write_text(json.dumps(fake_conf), encoding="utf-8")
|
||||
url = links._from_tauri()
|
||||
assert url == "https://github.com/debpalash/OmniVoice-Studio"
|
||||
|
||||
|
||||
def test_falls_back_to_pyproject_when_tauri_unreadable(monkeypatch, tmp_path):
|
||||
"""With the Tauri config set to a non-existent path, `_resolve()` falls
|
||||
back to the pyproject Repository URL."""
|
||||
links = _fresh_links_module()
|
||||
monkeypatch.setattr(links, "_TAURI_CONF", tmp_path / "missing.json")
|
||||
# pyproject is read from the real repo root, which has a Repository URL.
|
||||
url = links._resolve()
|
||||
assert url.startswith("https://github.com/")
|
||||
# Tauri path was missing → _from_tauri returned None → we ended up in
|
||||
# the pyproject branch (or the hardcoded fallback). Both are acceptable
|
||||
# https://github.com/... URLs.
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for backend/api/routers/settings.py perf endpoints + engine_env helper.
|
||||
|
||||
Covers INST-12 (Disable torch.compile Windows toggle):
|
||||
- GET /api/settings/perf/torch-compile-disabled returns the default + platform.
|
||||
- PUT round-trips through the settings_store (persisted as text "1"/"0").
|
||||
- PUT from a non-loopback origin is rejected with 403 (threat T-02-04).
|
||||
- The engine_env helper injects TORCH_COMPILE_DISABLE=1 only on win32 when
|
||||
the flag is set; on macOS/Linux the var is never injected.
|
||||
- When the flag is unset/false, the var is never injected.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_app(monkeypatch, tmp_path):
|
||||
"""Same isolation pattern as tests/backend/test_engine_spawn_token.py —
|
||||
new tmp DB + a fresh settings router instance per test."""
|
||||
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("HF_TOKEN", raising=False)
|
||||
monkeypatch.delenv("HUGGING_FACE_HUB_TOKEN", raising=False)
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if (
|
||||
mod == "core" or mod.startswith("core.")
|
||||
or mod == "services" or mod.startswith("services.")
|
||||
or mod == "api" or mod.startswith("api.")
|
||||
):
|
||||
del sys.modules[mod]
|
||||
|
||||
from core import db as _db
|
||||
_db.init_db()
|
||||
|
||||
from fastapi import FastAPI
|
||||
from api.routers import settings as settings_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(settings_router.router)
|
||||
return app
|
||||
|
||||
|
||||
def _client(app):
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(app, client=("127.0.0.1", 12345))
|
||||
|
||||
|
||||
def test_get_default_state(fresh_app, monkeypatch):
|
||||
"""Fresh DB → enabled=False, platform=<runtime>."""
|
||||
import huggingface_hub
|
||||
monkeypatch.setattr(huggingface_hub, "get_token", lambda: None)
|
||||
c = _client(fresh_app)
|
||||
r = c.get("/api/settings/perf/torch-compile-disabled")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["enabled"] is False
|
||||
assert body["platform"] in {"darwin", "linux", "win32"}
|
||||
|
||||
|
||||
def test_put_enabled_true_persists(fresh_app, monkeypatch):
|
||||
import huggingface_hub
|
||||
monkeypatch.setattr(huggingface_hub, "get_token", lambda: None)
|
||||
c = _client(fresh_app)
|
||||
r = c.put(
|
||||
"/api/settings/perf/torch-compile-disabled",
|
||||
json={"enabled": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["enabled"] is True
|
||||
r2 = c.get("/api/settings/perf/torch-compile-disabled")
|
||||
assert r2.json()["enabled"] is True
|
||||
|
||||
|
||||
def test_put_non_loopback_rejected(fresh_app):
|
||||
"""T-02-04: a PUT from a non-loopback origin is 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(fresh_app, client=("10.0.0.5", 12345)) as c:
|
||||
r = c.put(
|
||||
"/api/settings/perf/torch-compile-disabled",
|
||||
json={"enabled": True},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_value_round_trips_via_settings_store(fresh_app, monkeypatch):
|
||||
import huggingface_hub
|
||||
monkeypatch.setattr(huggingface_hub, "get_token", lambda: None)
|
||||
c = _client(fresh_app)
|
||||
c.put("/api/settings/perf/torch-compile-disabled", json={"enabled": True})
|
||||
from services import settings_store
|
||||
assert settings_store.get_text("perf.torch_compile_disabled") == "1"
|
||||
c.put("/api/settings/perf/torch-compile-disabled", json={"enabled": False})
|
||||
assert settings_store.get_text("perf.torch_compile_disabled") == "0"
|
||||
|
||||
|
||||
def test_env_injection_when_enabled_on_windows(monkeypatch, tmp_path):
|
||||
"""build_engine_env injects TORCH_COMPILE_DISABLE=1 on win32 + flag true."""
|
||||
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
|
||||
for mod in list(sys.modules):
|
||||
if mod == "core" or mod.startswith("core.") or mod == "services" or mod.startswith("services."):
|
||||
del sys.modules[mod]
|
||||
from core import db as _db
|
||||
_db.init_db()
|
||||
|
||||
from services import settings_store, engine_env
|
||||
settings_store.set_text("perf.torch_compile_disabled", "1")
|
||||
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
# token resolver must not blow up — monkeypatch.resolve to return None
|
||||
from services import token_resolver
|
||||
monkeypatch.setattr(token_resolver, "resolve", lambda **kw: None)
|
||||
|
||||
env = engine_env.build_engine_env(base_env={})
|
||||
assert env.get("TORCH_COMPILE_DISABLE") == "1"
|
||||
|
||||
|
||||
def test_no_env_injection_on_non_windows(monkeypatch, tmp_path):
|
||||
"""On macOS/Linux the var is never injected, even when the flag is set."""
|
||||
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
|
||||
for mod in list(sys.modules):
|
||||
if mod == "core" or mod.startswith("core.") or mod == "services" or mod.startswith("services."):
|
||||
del sys.modules[mod]
|
||||
from core import db as _db
|
||||
_db.init_db()
|
||||
|
||||
from services import settings_store, engine_env, token_resolver
|
||||
settings_store.set_text("perf.torch_compile_disabled", "1")
|
||||
monkeypatch.setattr(token_resolver, "resolve", lambda **kw: None)
|
||||
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
env = engine_env.build_engine_env(base_env={})
|
||||
assert "TORCH_COMPILE_DISABLE" not in env
|
||||
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
env = engine_env.build_engine_env(base_env={})
|
||||
assert "TORCH_COMPILE_DISABLE" not in env
|
||||
|
||||
|
||||
def test_no_env_injection_when_disabled(monkeypatch, tmp_path):
|
||||
"""Flag false on win32 → var not injected."""
|
||||
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
|
||||
for mod in list(sys.modules):
|
||||
if mod == "core" or mod.startswith("core.") or mod == "services" or mod.startswith("services."):
|
||||
del sys.modules[mod]
|
||||
from core import db as _db
|
||||
_db.init_db()
|
||||
|
||||
from services import settings_store, engine_env, token_resolver
|
||||
settings_store.set_text("perf.torch_compile_disabled", "0")
|
||||
monkeypatch.setattr(token_resolver, "resolve", lambda **kw: None)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
env = engine_env.build_engine_env(base_env={})
|
||||
assert "TORCH_COMPILE_DISABLE" not in env
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for scripts/validate-install-docs.py — checker B-5.
|
||||
|
||||
The validator is a CI gate that prevents docs/install/*.md from drifting out
|
||||
of sync with scripts/desktop-prod.sh. The validator itself must not drift —
|
||||
these tests pin the behaviours that matter:
|
||||
|
||||
- Clean state passes (exit 0, no errors printed).
|
||||
- A drift line in a `<!-- validate -->` block fails (exit 1, line + file
|
||||
surfaced on stderr).
|
||||
- `<!-- validate: skip -->` opts a block out of the comparison, even when
|
||||
the block diverges from the canonical script.
|
||||
- `$ ` and `>>> ` prompt prefixes are stripped before comparison.
|
||||
- CRLF line endings normalise to LF.
|
||||
- Trailing whitespace doesn't cause spurious failures.
|
||||
- Pure-comment and blank lines are skipped.
|
||||
|
||||
Each test builds a tmp_path-rooted fixture with the minimum file layout
|
||||
the validator needs (`docs/install/foo.md` + `scripts/desktop-prod.sh`) so
|
||||
the tests don't depend on the real repo state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "validate-install-docs.py"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validator_module():
|
||||
"""Import scripts/validate-install-docs.py as a module so we can call
|
||||
`main(root=...)` directly without spawning a subprocess."""
|
||||
spec = importlib.util.spec_from_file_location("validate_install_docs", SCRIPT_PATH)
|
||||
assert spec and spec.loader, "spec_from_file_location returned None"
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["validate_install_docs"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _make_root(tmp_path: Path, *, docs: dict[str, str], script: str) -> Path:
|
||||
"""Lay out the minimum repo skeleton the validator scans."""
|
||||
(tmp_path / "docs" / "install").mkdir(parents=True)
|
||||
for name, body in docs.items():
|
||||
(tmp_path / "docs" / "install" / name).write_text(body, encoding="utf-8")
|
||||
(tmp_path / "scripts").mkdir(parents=True)
|
||||
(tmp_path / "scripts" / "desktop-prod.sh").write_text(script, encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_clean_state_passes(validator_module, tmp_path, capsys):
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={
|
||||
"macos.md": (
|
||||
"# macOS\n\n"
|
||||
"<!-- validate -->\n"
|
||||
"```bash\n"
|
||||
'APP_NAME="OmniVoice Studio"\n'
|
||||
"```\n"
|
||||
),
|
||||
},
|
||||
script='APP_NAME="OmniVoice Studio"\n',
|
||||
)
|
||||
code = validator_module.main(root=root)
|
||||
out = capsys.readouterr()
|
||||
assert code == 0, out.err
|
||||
assert "OK" in out.out
|
||||
|
||||
|
||||
def test_drift_introduced_fails(validator_module, tmp_path, capsys):
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={
|
||||
"macos.md": (
|
||||
"# macOS\n\n"
|
||||
"<!-- validate -->\n"
|
||||
"```bash\n"
|
||||
"this-line-does-not-exist-in-the-script\n"
|
||||
"```\n"
|
||||
),
|
||||
},
|
||||
script='APP_NAME="OmniVoice Studio"\n',
|
||||
)
|
||||
code = validator_module.main(root=root)
|
||||
out = capsys.readouterr()
|
||||
assert code == 1
|
||||
assert "macos.md" in out.err
|
||||
assert "this-line-does-not-exist-in-the-script" in out.err
|
||||
|
||||
|
||||
def test_skip_marker_allows_divergence(validator_module, tmp_path, capsys):
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={
|
||||
"macos.md": (
|
||||
"# macOS\n\n"
|
||||
"<!-- validate: skip -->\n"
|
||||
"```bash\n"
|
||||
"this-block-can-diverge-from-script\n"
|
||||
"```\n"
|
||||
),
|
||||
},
|
||||
script='APP_NAME="OmniVoice Studio"\n',
|
||||
)
|
||||
code = validator_module.main(root=root)
|
||||
out = capsys.readouterr()
|
||||
assert code == 0, out.err
|
||||
|
||||
|
||||
def test_skip_marker_diverging_block_exit_zero(validator_module, tmp_path, capsys):
|
||||
"""B-5 case (b): an explicit `validate: skip` block diverging from the
|
||||
canonical script must NOT cause a non-zero exit."""
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={
|
||||
"win.md": (
|
||||
"<!-- validate: skip -->\n"
|
||||
"```bash\n"
|
||||
"setx HF_TOKEN hf_xxx\n"
|
||||
"```\n"
|
||||
),
|
||||
},
|
||||
script="something-else-entirely\n",
|
||||
)
|
||||
assert validator_module.main(root=root) == 0
|
||||
|
||||
|
||||
def test_prompt_prefix_stripped(validator_module, tmp_path, capsys):
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={
|
||||
"macos.md": (
|
||||
"<!-- validate -->\n"
|
||||
"```bash\n"
|
||||
"$ bun install\n"
|
||||
"```\n"
|
||||
),
|
||||
},
|
||||
script="bun install\n",
|
||||
)
|
||||
code = validator_module.main(root=root)
|
||||
assert code == 0, capsys.readouterr().err
|
||||
|
||||
|
||||
def test_python_prompt_prefix_stripped(validator_module, tmp_path, capsys):
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={
|
||||
"x.md": (
|
||||
"<!-- validate -->\n"
|
||||
"```python\n"
|
||||
">>> bun install\n"
|
||||
"```\n"
|
||||
),
|
||||
},
|
||||
script="bun install\n",
|
||||
)
|
||||
code = validator_module.main(root=root)
|
||||
assert code == 0, capsys.readouterr().err
|
||||
|
||||
|
||||
def test_crlf_normalization(validator_module, tmp_path, capsys):
|
||||
"""A docs file with CRLF line endings produces the same result as the LF
|
||||
version (exit 0 when the canonical content matches)."""
|
||||
crlf_doc = (
|
||||
"# macOS\r\n"
|
||||
"<!-- validate -->\r\n"
|
||||
"```bash\r\n"
|
||||
"bun install\r\n"
|
||||
"```\r\n"
|
||||
)
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={"macos.md": crlf_doc},
|
||||
script="bun install\n",
|
||||
)
|
||||
code = validator_module.main(root=root)
|
||||
assert code == 0, capsys.readouterr().err
|
||||
|
||||
|
||||
def test_trailing_whitespace_tolerated(validator_module, tmp_path, capsys):
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={
|
||||
"macos.md": (
|
||||
"<!-- validate -->\n"
|
||||
"```bash\n"
|
||||
"bun install \n" # trailing spaces in docs
|
||||
"```\n"
|
||||
),
|
||||
},
|
||||
script="bun install\n", # canonical has no trailing spaces
|
||||
)
|
||||
code = validator_module.main(root=root)
|
||||
assert code == 0, capsys.readouterr().err
|
||||
|
||||
|
||||
def test_blank_and_comment_only_lines_skipped(validator_module, tmp_path, capsys):
|
||||
root = _make_root(
|
||||
tmp_path,
|
||||
docs={
|
||||
"macos.md": (
|
||||
"<!-- validate -->\n"
|
||||
"```bash\n"
|
||||
"\n"
|
||||
"# A comment that explains the next step\n"
|
||||
"bun install\n"
|
||||
"# Another comment\n"
|
||||
"\n"
|
||||
"```\n"
|
||||
),
|
||||
},
|
||||
script="bun install\n",
|
||||
)
|
||||
code = validator_module.main(root=root)
|
||||
assert code == 0, capsys.readouterr().err
|
||||
|
||||
|
||||
def test_true_diff_distinguished_from_whitespace(validator_module, tmp_path, capsys):
|
||||
"""B-5 case (e): a real semantic diff (different command) must fail; a
|
||||
whitespace-only diff (trailing spaces) must pass."""
|
||||
# Whitespace-only diff — passes:
|
||||
root_ws = _make_root(
|
||||
tmp_path / "ws",
|
||||
docs={"x.md": "<!-- validate -->\n```bash\nbun install \n```\n"},
|
||||
script="bun install\n",
|
||||
)
|
||||
assert validator_module.main(root=root_ws) == 0
|
||||
|
||||
# Real semantic diff — fails:
|
||||
root_real = _make_root(
|
||||
tmp_path / "real",
|
||||
docs={"x.md": "<!-- validate -->\n```bash\nbun install --force\n```\n"},
|
||||
script="bun install\n",
|
||||
)
|
||||
code = validator_module.main(root=root_real)
|
||||
err = capsys.readouterr().err
|
||||
assert code == 1
|
||||
assert "bun install --force" in err
|
||||
Reference in New Issue
Block a user