feat(catalogue): one page, one axis — setup summary over per-family engines and weights

The Model Catalogue put the same decision on two axes: an Engines pane with
TTS/ASR/LLM tabs and a Models pane with TTS/ASR/Dictation/Diarisation
sections, dictation shown in both, plus storage stats, the HF token and the
voice-preview toggle parked on the model list. Settings → Voice still carried
Engines and Models entries that only pointed back here.

Now the page reads top-down: a SetupSummary (speech, transcription,
dictation, language model — engine, device, one status word, Change), the
engine list for one family, and that family's downloadable weights under it
(TTS under TTS; offline ASR, streaming dictation and diarisation under ASR;
nothing for LLM, whose engines bring their own). One storage line points at
Settings → Storage.

- ModelStoreTab takes a `family` and scopes sections and the recommendation
  preset to it (scopeReco); stats strip, HF-token toolbar and previews
  panel removed from it.
- Settings: Engines/Models categories and CataloguePointer removed; models
  directory → Storage, HF mirror → Network (both restart-flagged), voice
  previews → Storage. "Manage models" in disk usage opens the catalogue.
- Store: openCatalogue takes a family (pane key tolerated, ignored);
  pendingCatalogueTab gone.
- Engine matrix title is now the locale's plain "Engines".
- i18n: catalogue.* summary keys in all 21 locales; pane/pointer keys dropped.
- Docs: "Model Catalogue → Engines" is "Model Catalogue"; "→ Models" is
  "→ Downloaded weights".
This commit is contained in:
Palash Debnath
2026-09-10 06:46:09 -07:00
parent dea884a22d
commit 3cae853440
77 changed files with 1184 additions and 946 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ We exclude every `nvidia.*` wheel from the Apple Silicon build (saves ~2 GB) and
For us:
- **Base DMG ships MPS + MLX path only.** Excludes `nvidia.*`, `triton`, `flash-attn`, anything CUDA-specific in the spec.
- **Optional pack: VoxCPM2** (requires CUDA). Not installed by default. Model Catalogue → Engines → "Install VoxCPM2" triggers download from our `voxcpm2-cu128-v1.tar.gz` release asset.
- **Optional pack: VoxCPM2** (requires CUDA). Not installed by default. Model Catalogue → "Install VoxCPM2" triggers download from our `voxcpm2-cu128-v1.tar.gz` release asset.
- **Optional pack: pyannote** (HF-token gated). Default off. Settings → Speaker diarisation → "Enable" prompts for HF token, downloads + installs.
- **Optional pack: MOSS-TTS-Nano.** Same pattern.
+3 -3
View File
@@ -60,7 +60,7 @@ State is reported at **Settings → About** / `GET /system/info`:
- `fast_download.xet_installed``hf_xet` present (true)
- `fast_download.xet_active` — whether Xet actually drives downloads (false by
default, because of `HF_HUB_DISABLE_XET`)
- the **⚡ fast download** badge in **Model Catalogue → Models** appears only when Xet
- the **⚡ fast download** badge in **Model Catalogue → Downloaded weights** appears only when Xet
is *active*.
The backend logs one line at startup, e.g.
@@ -148,7 +148,7 @@ failed download at once. Caveats:
## Cancelling a download
**Model Catalogue → Models** lets you cancel an in-flight install. Cancellation stops
**Model Catalogue → Downloaded weights** lets you cancel an in-flight install. Cancellation stops
further retries and clears the failure cooldown so you can restart
immediately. A file that's already streaming finishes first — cancellation
takes effect at the next retry boundary.
@@ -162,6 +162,6 @@ takes effect at the next retry boundary.
High-performance mode only helps if RAM and bandwidth are plentiful.
- **"download finished but no model weights were found"** — the download was
interrupted and left a partial snapshot. Delete the model in
**Model Catalogue → Models** and install it again.
**Model Catalogue → Downloaded weights** and install it again.
- **Out of disk** — model sizes are shown in the catalog; free space or change
the cache location with `HF_HOME` / `HF_HUB_CACHE`.
+1 -1
View File
@@ -1,7 +1,7 @@
# Engine guides
One page per engine: what it's for, what it needs, how to enable it, and its
quirks. Select engines in **Model Catalogue → Engines** (or quick-switch with
quirks. Select engines in **Model Catalogue** (or quick-switch with
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>), or pin one with
`OMNIVOICE_TTS_BACKEND` / `OMNIVOICE_ASR_BACKEND`.
+3 -3
View File
@@ -80,11 +80,11 @@ instead of that dedicated-VRAM floor.
```
Alternatively set `OMNIVOICE_AUDIOCPP_DIR` to the directory containing it.
3. Restart VoiceStudio, open **Model Catalogue → Models**, find
3. Restart VoiceStudio, open **Model Catalogue → Downloaded weights**, find
**Breeze-TTS-2 Q8_0 for audio.cpp**, review its research/non-commercial
license note, and click **Install**. Generation never starts this ~4.73 GiB
download automatically.
4. Pick `audiocpp` in **Model Catalogue → Engines**. The server starts
4. Pick `audiocpp` in **Model Catalogue**. The server starts
lazily on first generate (`server.json` + `server.log` live under the app
data `audiocpp/` directory).
@@ -131,7 +131,7 @@ The managed loopback port may be taken. Check `server.log` next to
### `Breeze-TTS-2 ... not installed` or `package ... not completely installed`
Install the model from **Model Catalogue → Models**. If an interrupted install
Install the model from **Model Catalogue → Downloaded weights**. If an interrupted install
left it incomplete, use **Reinstall** there. If the error persists after a
complete reinstall, file an issue with the package listing.
+1 -1
View File
@@ -56,7 +56,7 @@ Then point VoiceStudio at the clone and restart:
- **macOS/Linux:** `export OMNIVOICE_CONFUCIUS4_TTS_DIR=/path/to/Confucius4-TTS`
- **Windows (PowerShell):** `[Environment]::SetEnvironmentVariable("OMNIVOICE_CONFUCIUS4_TTS_DIR","C:\path\to\Confucius4-TTS","User")`
Select **Confucius4-TTS** in Model Catalogue → Engines. The first synthesize triggers
Select **Confucius4-TTS** in Model Catalogue. The first synthesize triggers
the weight downloads above, then generates.
### Optional overrides
+3 -3
View File
@@ -11,14 +11,14 @@ pins `transformers>=5.3` — the same isolation primitive used by
[IndexTTS-2](indextts.md) and [MOSS-TTS-v1.5](moss-tts-v15.md).
> **Opt-in, and never a default.** dots.tts is selected explicitly in
> **Model Catalogue → Engines** (or `OMNIVOICE_TTS_BACKEND=dots-tts`). It is not
> **Model Catalogue** (or `OMNIVOICE_TTS_BACKEND=dots-tts`). It is not
> part of the default install.
## Platform support
- **Linux / macOS only.** dots.tts's upstream package declares Linux and
macOS classifiers and has **no Windows install path**. On Windows the
engine reports itself unavailable in **Model Catalogue → Engines** with a clear
engine reports itself unavailable in **Model Catalogue** with a clear
reason — run VoiceStudio under WSL2 or use a Linux/macOS host.
- **No MPS.** Upstream device selection is CUDA-or-CPU with no Metal branch,
so on Apple Silicon the official package runs on **CPU** (slow but
@@ -60,7 +60,7 @@ dots.tts is **not** bundled (large checkpoint + conflicting `transformers`).
source ~/.zshrc
```
5. Restart VoiceStudio. dots.tts appears in **Model Catalogue → Engines** with
5. Restart VoiceStudio. dots.tts appears in **Model Catalogue** with
`available: true` and `isolation_mode: subprocess`.
## Venv resolution order
+2 -2
View File
@@ -13,7 +13,7 @@ only the process boundary is new.
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the crash-isolated row, or
- **Model Catalogue**, ASR tab → **Use** on the crash-isolated row, or
- pin it with `OMNIVOICE_ASR_BACKEND=faster-whisper-isolated`.
It is never picked by auto-detect — it's an explicit opt-in escape hatch.
@@ -56,6 +56,6 @@ Weights download on first load — see
"sidecar crashed" error and the backend stays up — retry to respawn.
- **cuDNN 8 is still required on CUDA** — same CTranslate2 requirement as the
in-process engine. It's checked up front so a missing cuDNN 8 shows as
"unavailable" in Model Catalogue → Engines instead of a sidecar that
"unavailable" in Model Catalogue instead of a sidecar that
silently fails every transcribe
([#1371](https://github.com/debpalash/VoiceStudio/issues/1371)).
+1 -1
View File
@@ -7,7 +7,7 @@ fallback on non-Apple machines.
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the Faster-Whisper row, or
- **Model Catalogue**, ASR tab → **Use** on the Faster-Whisper row, or
- pin it with `OMNIVOICE_ASR_BACKEND=faster-whisper`.
Auto-detect only picks it when [whisperx](whisperx.md) is unavailable.
+1 -1
View File
@@ -10,7 +10,7 @@ WhisperX remains the cross-platform default.
## Selecting it
- Install it into the app venv: `uv pip install funasr`.
- Then **Model Catalogue → Engines**, ASR tab → **Use** on the FunASR row, or
- Then **Model Catalogue**, ASR tab → **Use** on the FunASR row, or
`OMNIVOICE_ASR_BACKEND=funasr`.
Auto-detect never picks it; it's an explicit opt-in.
+1 -1
View File
@@ -24,7 +24,7 @@ HTTP.
python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/tts_infer.yaml
```
2. Select the engine via **Model Catalogue → Engines** or
2. Select the engine via **Model Catalogue** or
`OMNIVOICE_TTS_BACKEND=gpt-sovits`.
VoiceStudio marks the engine available only when the server responds
+2 -2
View File
@@ -14,7 +14,7 @@ runtime. Existing user-managed IndexTTS-2 environments remain supported.
IndexTTS 2.5 is not bundled because its source environment and model weights
require substantial disk space.
1. Open **Model Catalogue → Engines**.
1. Open **Model Catalogue**.
2. Expand **IndexTTS 2.5** and select **Install**.
3. Keep VoiceStudio open while source, dependencies, and weights download.
@@ -97,7 +97,7 @@ IndexTTS-2 installations continue receiving their `target_tokens` control.
### Engine unavailable
Use **Model Catalogue → Engines → IndexTTS 2.5 → Install**. For a manual install,
Use **Model Catalogue → IndexTTS 2.5 → Install**. For a manual install,
confirm that the configured directory contains:
```text
+1 -1
View File
@@ -20,7 +20,7 @@ only — but a much faster and much smaller install.
pip install kittentts
```
Then select the engine via **Model Catalogue → Engines** or
Then select the engine via **Model Catalogue** or
`OMNIVOICE_TTS_BACKEND=kittentts`.
## Voices
+2 -2
View File
@@ -19,7 +19,7 @@ platforms never reports as available
pip install mlx-audio
```
Then select the engine via **Model Catalogue → Engines** or
Then select the engine via **Model Catalogue** or
`OMNIVOICE_TTS_BACKEND=mlx-audio`.
## Model selection
@@ -36,7 +36,7 @@ One backend hosts many models. The curated set:
| `melotts` | `mlx-community/MeloTTS-English-v3-MLX` | lightweight VITS |
| `outetts` | `mlx-community/Llama-OuteTTS-1.0-1B-4bit` | LM-based |
Pick a model in the **Model Catalogue → Engines** curated picker
Pick a model in the **Model Catalogue** curated picker
([#981](https://github.com/debpalash/VoiceStudio/issues/981)) or set
`OMNIVOICE_MLX_AUDIO_MODEL` to either a curated key (`kokoro`) or any full
HF repo id. The env var overrides the persisted UI choice.
+1 -1
View File
@@ -11,7 +11,7 @@ on every Apple Silicon machine
## Selecting it
- Nothing to do on Apple Silicon — auto-detect prefers it there.
- Or explicitly: **Model Catalogue → Engines**, ASR tab → **Use**, or
- Or explicitly: **Model Catalogue**, ASR tab → **Use**, or
`OMNIVOICE_ASR_BACKEND=mlx-whisper`.
## Best at
+1 -1
View File
@@ -11,7 +11,7 @@ where even int8 whisper-large is too slow.
- Install one of the runtimes into the app venv:
`uv pip install moonshine-onnx` (lighter, tried first) or
`moonshine-voice`.
- Then **Model Catalogue → Engines**, ASR tab → **Use** on the Moonshine row,
- Then **Model Catalogue**, ASR tab → **Use** on the Moonshine row,
or `OMNIVOICE_ASR_BACKEND=moonshine`.
Auto-detect never picks it; it's an explicit opt-in.
+1 -1
View File
@@ -25,7 +25,7 @@ cd MOSS-TTS-Nano
uv pip install -e .
```
Then select the engine via **Model Catalogue → Engines** or
Then select the engine via **Model Catalogue** or
`OMNIVOICE_TTS_BACKEND=moss-tts-nano`.
## Model selection
+2 -2
View File
@@ -13,7 +13,7 @@ interpreter, so MOSS runs behind
`backend/services/subprocess_backend.py::SubprocessBackend`.
> **Opt-in, and never a default.** MOSS-TTS-v1.5 is selected explicitly in
> **Model Catalogue → Engines** (or `OMNIVOICE_TTS_BACKEND=moss-tts-v15`). It is
> **Model Catalogue** (or `OMNIVOICE_TTS_BACKEND=moss-tts-v15`). It is
> not part of the default install and does not change VoiceStudio's
> out-of-the-box behaviour on any platform.
@@ -75,7 +75,7 @@ into an isolated venv on demand.
[Environment]::SetEnvironmentVariable("OMNIVOICE_MOSS_TTS_V15_DIR","$env:USERPROFILE\code\MOSS-TTS","User")
```
5. Restart VoiceStudio. MOSS-TTS-v1.5 appears in **Model Catalogue → Engines** with
5. Restart VoiceStudio. MOSS-TTS-v1.5 appears in **Model Catalogue** with
`available: true` and `isolation_mode: subprocess`.
## Venv resolution order
+1 -1
View File
@@ -29,7 +29,7 @@ instead — same model family, no NeMo dependency:
Only meaningful if you've set up `nemo_toolkit[asr]` in a **separate,
dedicated Python environment** that runs the backend:
- **Model Catalogue → Engines**, ASR tab → **Use** on the Parakeet TDT row, or
- **Model Catalogue**, ASR tab → **Use** on the Parakeet TDT row, or
- `OMNIVOICE_ASR_BACKEND=nemo-parakeet`.
Auto-detect never picks it; it's an explicit opt-in.
+2 -2
View File
@@ -32,10 +32,10 @@ against the same table (an F32 reference quant, ~3.2 GB, is override-only).
## Setup
Nothing to install: installer and CI builds bundle the binary for your
platform. Select the engine via **Model Catalogue → Engines** or
platform. Select the engine via **Model Catalogue** or
`OMNIVOICE_TTS_BACKEND=omnivoice-gguf`. The quant weights download on first
use (see [downloading-models.md](../downloading-models.md)) — install them
ahead of time from **Model Catalogue → Models** if you want the first
ahead of time from **Model Catalogue → Downloaded weights** if you want the first
generation to be quick; a long first render is the download, not a hang.
**Source checkouts:** the repo ships zero-byte placeholders in `bin/` — real
+1 -1
View File
@@ -32,7 +32,7 @@ lower call overhead.
## Selecting it
- **Model Catalogue → Engines** on CUDA, ROCm, or CPU, or
- **Model Catalogue** on CUDA, ROCm, or CPU, or
- `OMNIVOICE_TTS_BACKEND=omnivoice-subprocess`
The explicit engine is opt-in on CUDA, ROCm, and CPU. On Apple Silicon it is
+2 -2
View File
@@ -39,7 +39,7 @@ quantized native binary with a much smaller memory footprint.
OmniVoice is the default, so normally there is nothing to do. If you switched
away and want it back:
- **Model Catalogue → Engines**, or
- **Model Catalogue**, or
- set `OMNIVOICE_TTS_BACKEND=omnivoice`.
The env var overrides the persisted UI choice.
@@ -103,7 +103,7 @@ The env var overrides the persisted UI choice.
above — switch to OmniVoice GGUF or close other GPU apps.
- First generation is slow: the first call downloads multi-GB weights. To
keep the first render quick, install the model ahead of time from
**Model Catalogue → Models** — a long first generate is almost always the
**Model Catalogue → Downloaded weights** — a long first generate is almost always the
download, not a hang.
- General install issues: [install/troubleshooting.md](../install/troubleshooting.md).
+1 -1
View File
@@ -9,7 +9,7 @@ model installation and compute.
## Setup
Everything lives on one screen — **Model Catalogue → Engines**, **ASR** tab:
Everything lives on one screen — **Model Catalogue**, **ASR** tab:
1. The **OpenAI-compatible (remote server)** row shows as unavailable until
a server is configured. The config panel appears **below the engine
+2 -2
View File
@@ -12,10 +12,10 @@ Apple Silicon source installs since 0.3.22**.
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the Parakeet TDT v3
- **Model Catalogue**, ASR tab → **Use** on the Parakeet TDT v3
(MLX) row, or `OMNIVOICE_ASR_BACKEND=parakeet-mlx`.
- **Dictation prefers it automatically**: once the model weights are
installed (Model Catalogue → Models — the auto-pick never triggers a
installed (Model Catalogue → Downloaded weights — the auto-pick never triggers a
download), live dictation/capture uses it whenever your system language is
one of the 25 covered European languages. Other languages keep the
multilingual Whisper engine, so dictation coverage never regresses.
+4 -4
View File
@@ -24,18 +24,18 @@ for this model.
uv sync --extra pockettts
```
(Or enable it from **Model Catalogue → Engines**.)
(Or enable it from **Model Catalogue**.)
2. **Accept the license in-app**
([#1306](https://github.com/debpalash/VoiceStudio/issues/1306)). The code
is MIT and the weights are CC-BY-4.0, but the weights are **gated on
HuggingFace** behind an access agreement with an acceptable-use clause.
VoiceStudio surfaces this before first use: the engine stays unavailable
until you review and accept in **Model Catalogue → Engines → PocketTTS**.
until you review and accept in **Model Catalogue → PocketTTS**.
You also need HuggingFace access to the gated repo (see
[downloading-models.md](../downloading-models.md) for token setup).
3. Select the engine via **Model Catalogue → Engines** or
3. Select the engine via **Model Catalogue** or
`OMNIVOICE_TTS_BACKEND=pockettts`.
## Platform notes
@@ -77,7 +77,7 @@ for this model.
## Troubleshooting
- "pocket_tts package not installed": run the `uv sync` above.
- "license not accepted": open **Model Catalogue → Engines → PocketTTS**
- "license not accepted": open **Model Catalogue → PocketTTS**
and review/accept.
- Timeouts on a slow connection: raise
`OMNIVOICE_POCKETTTS_RECV_TIMEOUT_S` for the first (download-heavy) run.
+1 -1
View File
@@ -9,7 +9,7 @@ genuinely uses **AMD ROCm** GPUs, so auto-detect picks it on ROCm hosts
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the PyTorch Whisper
- **Model Catalogue**, ASR tab → **Use** on the PyTorch Whisper
row, or `OMNIVOICE_ASR_BACKEND=pytorch-whisper`.
- `OMNIVOICE_ASR_BACKEND=omnivoice` is accepted as a compatibility alias and
selects this same PyTorch-native ASR path on ROCm hosts.
+2 -2
View File
@@ -10,8 +10,8 @@ partials either way.
## Selecting it
- Ensure `sherpa-onnx` is installed (`uv add sherpa-onnx` on source installs).
- Pick a dictation model in the app (Model Catalogue → Models lists the
selectable set below), or **Model Catalogue → Engines**, ASR tab → **Use**, or
- Pick a dictation model in the app (Model Catalogue → Downloaded weights lists the
selectable set below), or **Model Catalogue**, ASR tab → **Use**, or
pin `OMNIVOICE_ASR_BACKEND=sherpa-onnx-asr`.
- `OMNIVOICE_SHERPA_ASR_MODEL` selects the model — default
`sherpa-whisper-tiny`.
+1 -1
View File
@@ -30,7 +30,7 @@ TTS model directory.
export OMNIVOICE_SHERPA_MODEL=/path/to/model-dir
```
4. Select the engine via **Model Catalogue → Engines** or
4. Select the engine via **Model Catalogue** or
`OMNIVOICE_TTS_BACKEND=sherpa-onnx`.
The directory must contain `model.onnx` and `tokens.txt`. Sherpa-ONNX ships
+4 -4
View File
@@ -19,16 +19,16 @@ crashes and cold init never block the rest of VoiceStudio.
uv sync --extra supertonic
```
(Or enable it from **Model Catalogue → Engines**, which installs the
(Or enable it from **Model Catalogue**, which installs the
pinned `supertonic` wheel for you.)
2. **Accept the license in-app.** First use is gated behind an explicit
acceptance dialog: the inference SDK is MIT, but the model weights are
**OpenRAIL-M**, which carries use restrictions. The engine stays
unavailable until you review and accept in **Model Catalogue → Engines →
unavailable until you review and accept in **Model Catalogue →
Supertonic-3**.
3. Select the engine via **Model Catalogue → Engines** or
3. Select the engine via **Model Catalogue** or
`OMNIVOICE_TTS_BACKEND=supertonic3`.
The first synthesis cold-downloads ~400 MB of model weights, pinned to an
@@ -66,7 +66,7 @@ log line.
- "supertonic package not installed": run the `uv sync` above or enable
from the Model Catalogue.
- "license not accepted": open **Model Catalogue → Engines → Supertonic-3**
- "license not accepted": open **Model Catalogue → Supertonic-3**
and accept.
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
+1 -1
View File
@@ -31,7 +31,7 @@ pip install "voxcpm>=2.0.3"
That is a version **floor**, not a pin — an older install still works, but
the engine logs an upgrade hint at load time. Then select the engine via
**Model Catalogue → Engines** or `OMNIVOICE_TTS_BACKEND=voxcpm2`.
**Model Catalogue** or `OMNIVOICE_TTS_BACKEND=voxcpm2`.
## Model selection
+1 -1
View File
@@ -8,7 +8,7 @@ prefers it wherever CTranslate2 can use the GPU.
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the WhisperX row, or
- **Model Catalogue**, ASR tab → **Use** on the WhisperX row, or
- pin it with `OMNIVOICE_ASR_BACKEND=whisperx` (the env var always wins over
the Settings pick; with neither set, auto-detect chooses per-hardware).
+1 -1
View File
@@ -129,7 +129,7 @@ the default engine's taxonomy, so free-text instruct currently needs the API
Setup: clone + install [CosyVoice](https://github.com/FunAudioLLM/CosyVoice)
(non-trivial: `git clone --recursive`, its requirements, SoX), then set
`OMNIVOICE_COSYVOICE_MODEL` to the model directory and select it in
Model Catalogue → Engines. CUDA or CPU; MPS is unverified upstream.
Model Catalogue. CUDA or CPU; MPS is unverified upstream.
### VoxCPM2 (opt-in)
+1 -1
View File
@@ -215,7 +215,7 @@ ROCm container `omnivoice-studio-rocm` (CPU: `omnivoice-studio`, NVIDIA:
name means torch can see the GPU.) That check alone isn't proof the app is
using it: **Settings → Performance & Device** shows the device VoiceStudio
actually resolved.
**Model Catalogue → Engines** should report both `omnivoice` and
**Model Catalogue** should report both `omnivoice` and
`omnivoice-subprocess` as accelerated on ROCm, rather than a CPU-fallback
warning.
If it reads `cpu` while the command above prints `True`, the backend log line
+2 -2
View File
@@ -157,7 +157,7 @@ without the quarantine step.
- **Apple Silicon (M-series):** VoiceStudio 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.
Installing the **Parakeet TDT v3 (MLX)** model from **Model Catalogue → Models**
Installing the **Parakeet TDT v3 (MLX)** model from **Model Catalogue → Downloaded weights**
additionally makes dictation/capture prefer the `parakeet-mlx` engine
(25 European languages, word timestamps, ~2 GB unified memory) — it is never
downloaded without that explicit install, and it is only auto-preferred when
@@ -170,7 +170,7 @@ without the quarantine step.
works only when pointed at a remote backend (**Settings → Sharing → Remote
backend**).
The picker in **Model Catalogue → Engines** shows which backend is active.
The picker in **Model Catalogue** shows which backend is active.
## Hugging Face token (optional but recommended)
+8 -8
View File
@@ -89,7 +89,7 @@ uv pip install --reinstall transformers
```
Or, as a quick workaround, switch ASR to **faster-whisper** in
**Model Catalogue → Models**. If it recurs, add the backend **`.venv`** to your
**Model Catalogue → Downloaded weights**. If it recurs, add the backend **`.venv`** to your
antivirus exclusions (see §1). Newer builds classify this error and show the
reinstall hint directly instead of a bare path + "try restarting".
@@ -141,7 +141,7 @@ uv pip install --reinstall pytorch-lightning
If it recurs, add the backend **`.venv`** to your antivirus exclusions (see
§1). Since this fix landed the app also degrades gracefully: WhisperX is
marked unavailable (Model Catalogue → Engines shows why, with this repair command)
marked unavailable (Model Catalogue shows why, with this repair command)
and dubbing automatically falls through to **faster-whisper** instead of
failing outright.
@@ -205,7 +205,7 @@ itself does not grant access.
license and prohibited-use conditions, share the requested contact details,
and accept the conditions.
2. Open **Settings → API Keys** and save a read token from that same account.
3. Open **Model Catalogue → Engines**, review and accept the PocketTTS terms locally,
3. Open **Model Catalogue**, review and accept the PocketTTS terms locally,
then retry. VoiceStudio stores this acknowledgement only on your machine.
## 3. Gatekeeper quarantine on macOS
@@ -408,7 +408,7 @@ Intel-Mac wheels, so this entry only applies to historical installs (see
## 10. Windows: `Could not locate cudnn_ops_infer64_8.dll` during transcription
**Symptom:** on Windows + NVIDIA, transcription/dubbing fails and the backend
log shows `Could not locate cudnn_ops_infer64_8.dll`. Model Catalogue → Models shows
log shows `Could not locate cudnn_ops_infer64_8.dll`. Model Catalogue → Downloaded weights shows
WhisperX or faster-whisper selected.
On builds before this was fixed, the failure looked much worse than a failed
@@ -444,7 +444,7 @@ uv pip install --no-deps --python .venv\Scripts\python.exe --target .venv\Lib\si
(On Linux the target is `.venv/lib/pythonX.Y/site-packages/cudnn8_compat`.)
Or sidestep cuDNN 8 entirely: switch the ASR backend to **PyTorch Whisper** in
**Model Catalogue → Models**. It runs on PyTorch's own stack (cuDNN 9, bundled with
**Model Catalogue → Downloaded weights**. It runs on PyTorch's own stack (cuDNN 9, bundled with
torch) and needs no cuDNN-8 DLL — it loads its Whisper pipeline on demand (no
extra env var).
@@ -600,12 +600,12 @@ did was `generate:start (audio)`, a dub, or a dictation.
**Fix — reduce ASR load (any one of these):**
1. **Pick a smaller ASR model / engine** in **Model Catalogue → Models** — e.g.
1. **Pick a smaller ASR model / engine** in **Model Catalogue → Downloaded weights** — e.g.
faster-whisper **medium** or **small**, instead of large-v3. Biggest win on
low-VRAM GPUs.
2. **Free VRAM**: **Flush the TTS model** before dubbing so ASR isn't competing
for memory (top toolbar → Flush → "Unload all + flush", or per-model from
Model Catalogue → Models — see [Flush caches / Unload resident model](../performance.md#flush-caches--unload-resident-model)
Model Catalogue → Downloaded weights — see [Flush caches / Unload resident model](../performance.md#flush-caches--unload-resident-model)
for exactly what it frees and the API equivalents for scripts), or
3. **Run ASR on CPU** (slower but reliable) if your GPU is small.
4. **Test with a 10-second clip** first — if that returns quickly, it confirms a
@@ -675,7 +675,7 @@ hammering — on a 1-worker machine, concurrent requests serialize by design.
recovering the underlying hang — the wedged thread keeps its VRAM until the app
exits. The error message will then recommend switching the ASR engine to
**Faster-Whisper (crash-isolated subprocess)** (`faster-whisper-isolated`) in
**Model Catalogue → Engines**: it runs transcription in a separate process that can be
**Model Catalogue**: it runs transcription in a separate process that can be
force-killed to reclaim a hung transcribe *and* its VRAM, at a small per-call
overhead. It reuses your existing faster-whisper install (nothing extra to
download). VoiceStudio never switches engines automatically — this stays your
+1 -1
View File
@@ -25,7 +25,7 @@ on your own machine: no accounts, no API keys, no cloud.
|---|---|---|
| Speaker encoder + reference utterance | Reference clip in the **Voice Clone** workflow ("From audio") | No separate embedding step — zero-shot engines condition on the clip directly |
| Saved speaker embeddings (`.npy`) | **Voice Profiles** — save a clone once, reuse it everywhere | Exportable as portable `.ovsvoice` bundles |
| Synthesizer + vocoder choice (Tacotron 2 · WaveRNN / Griffin-Lim) | **TTS engine choice** — Model Catalogue → Engines | 14 engines, from CPU-realtime to GPU heavyweights; per-engine GPU preflight |
| Synthesizer + vocoder choice (Tacotron 2 · WaveRNN / Griffin-Lim) | **TTS engine choice** — Model Catalogue | 14 engines, from CPU-realtime to GPU heavyweights; per-engine GPU preflight |
| The Toolbox GUI (`demo_toolbox.py`) | The app itself | Record or drop a clip, type text, synthesize — same loop, no `python demo_toolbox.py` |
| `demo_cli.py` / scripting your own pipeline | Local REST API (OpenAI-compatible, `http://localhost:3900/v1`), `omnivoice-infer` CLI, MCP server | See the [API section of the README](../../README.md#openai-api) |
| Training your own encoder / synthesizer / vocoder | Partial — see ["What RTVC did that VoiceStudio doesn't"](#what-rtvc-did-that-omnivoice-doesnt) | Fine-tuning the bundled model is documented; RTVC-style three-stage research training is not what this project is |
+3 -3
View File
@@ -38,7 +38,7 @@ Before touching any knob, check these — they account for most slowness reports
- **Settings → About → Run self-check** (the `/system/diagnose` endpoint)
warns explicitly: *"cpu (no GPU acceleration detected)"* with a hint
about drivers.
- **Model Catalogue → Engines** shows a routing badge per engine — "GPU active",
- **Model Catalogue** shows a routing badge per engine — "GPU active",
"CPU fallback", or "CPU" — with the *reason* shown as small text under
the badge (full text on hover).
Note: **GPU acceleration on Windows is NVIDIA/CUDA-only** — AMD and Intel
@@ -183,7 +183,7 @@ drain, or restart the backend, and then Flush.
- **Unload all + flush** — the above **plus** fully unloads the resident
TTS model. Frees the most memory; the next generation pays the ~8 s
reload.
- **Model Catalogue → Models** — rows whose weights are resident right now show an
- **Model Catalogue → Downloaded weights** — rows whose weights are resident right now show an
"In memory" badge with the same per-model **Unload** button.
**From a script** (the local API on port 3900), the same operations:
@@ -234,7 +234,7 @@ CPU ASR, the crash-isolated ASR engine).
to 3-4 concurrent generations (API/batch workloads); ≤10 GB deliberately
serializes.
- **CPU-only**: expect ~2x slower than MPS, more against CUDA. Prefer the
smaller/faster engines (see Model Catalogue → Engines) and short reference clips.
smaller/faster engines (see Model Catalogue) and short reference clips.
## Measuring instead of guessing
@@ -218,7 +218,7 @@ export default function EngineQuickSwitch({
type="button"
onClick={() => {
setOpen(false);
useAppStore.getState().openCatalogue({ pane: 'engines', family });
useAppStore.getState().openCatalogue({ family });
}}
className="mt-[3px] flex items-center gap-[3px] border-0 bg-transparent px-[7px] py-[5px] text-left text-[11px] text-[color:var(--chrome-fg-muted)] hover:text-[color:var(--chrome-fg)]"
>
+1 -3
View File
@@ -517,9 +517,7 @@ export default function Header({
className="flex items-center gap-1 border-0 bg-transparent p-2 text-xs text-[var(--chrome-fg-muted)] cursor-pointer hover:text-[var(--chrome-fg)]"
onClick={() => {
setFlushOpen(false);
useAppStore
.getState()
.openCatalogue({ pane: 'engines', family: engineFamily });
useAppStore.getState().openCatalogue({ family: engineFamily });
}}
>
{t('header.label_catalogue')} <ChevronRight size={12} />
@@ -0,0 +1,236 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast';
import { Download, RefreshCw } from 'lucide-react';
import { apiJson } from '../../api/client';
import { useEngines, useInstallModel, useRecommendations } from '../../api/hooks';
import { useAppStore } from '../../store';
import { Badge, Button } from '../../ui';
/**
* SetupSummary the Model Catalogue's first screen: what the app will use
* right now for speech, transcription, dictation and the language model, one
* line each, and a "Change" that jumps to that family's engine list below.
*
* Most users take the recommended engines and never come back; this is the
* view built for them. Everything that used to compete for attention on the
* old two-pane page (engine matrix, model store, stats) now sits under it,
* reached only through Change.
*/
const DEVICE_LABEL = { cuda: 'CUDA', mps: 'MPS', rocm: 'ROCm', cpu: 'CPU', directml: 'DirectML' };
const STATUS_TONE = {
ready: 'success',
cpu: 'warn',
setup: 'warn',
off: 'neutral',
missing: 'warn',
};
/** Pure: reduce one family's /engines payload to {name, device, status}. */
export function summarizeFamily(family, familyData) {
const active = familyData?.active;
const backends = familyData?.backends || [];
if (family === 'llm' && (!active || active === 'off')) {
return { name: null, device: null, status: 'off' };
}
const entry = backends.find((b) => b.id === active);
const name = entry?.display_name || active || null;
if (!entry || entry.available === false) return { name, device: null, status: 'setup' };
const routing = entry.routing_status;
const device =
routing === 'n/a' ? 'remote' : DEVICE_LABEL[entry.effective_device] || entry.effective_device;
return {
name,
device: device || null,
status: routing === 'cpu_fallback' ? 'cpu' : 'ready',
};
}
/** Pure: the dictation line from /dictation/models + the model_id pref. */
export function summarizeDictation(data, modelId) {
const models = Array.isArray(data?.models) ? data.models : [];
const selected =
models.find((m) => m.id === modelId) || models.find((m) => m.recommended) || models[0];
if (!selected) return { name: null, device: null, status: 'setup' };
if (data?.engine_available === false)
return { name: selected.label, device: null, status: 'setup' };
return {
name: selected.label,
device: DEVICE_LABEL.cpu,
status: selected.installed ? 'ready' : 'missing',
};
}
export default function SetupSummary({ onChange }) {
const { t } = useTranslation();
const enginesQuery = useEngines();
const recoQuery = useRecommendations();
const installMutation = useInstallModel();
const [installing, setInstalling] = useState(false);
const modelId = useAppStore((s) => s.dictationModelId);
const loadPrefs = useAppStore((s) => s.loadDictationPrefs);
const dictationLoaded = useAppStore((s) => s.dictationLoaded);
useEffect(() => {
if (!dictationLoaded) loadPrefs?.();
}, [dictationLoaded, loadPrefs]);
// Same query key as the quick-menu picker so both read one cache entry.
const dictationQuery = useQuery({
queryKey: ['dictation-models'],
queryFn: () => apiJson('/dictation/models'),
staleTime: 10_000,
retry: false,
});
const engines = enginesQuery.data;
const rows = useMemo(
() => [
{
key: 'speech',
family: 'tts',
label: t('header.speech'),
...summarizeFamily('tts', engines?.tts),
},
{
key: 'transcription',
family: 'asr',
label: t('projects.transcription'),
...summarizeFamily('asr', engines?.asr),
},
{
key: 'dictation',
family: 'asr',
label: t('settings.dictation'),
...summarizeDictation(dictationQuery.data, modelId),
},
{
key: 'llm',
family: 'llm',
label: t('models.role_llm'),
...summarizeFamily('llm', engines?.llm),
},
],
[t, engines, dictationQuery.data, modelId],
);
const statusLabel = {
ready: t('models.ready_badge'),
cpu: t('catalogue.status_cpu'),
setup: t('catalogue.status_setup'),
off: t('catalogue.status_off'),
missing: t('models.not_installed'),
};
const reco = recoQuery.data;
const missing = reco?.models?.filter((m) => !m.installed) || [];
const installRest = async () => {
if (missing.length === 0) return;
setInstalling(true);
try {
await Promise.all(missing.map((m) => installMutation.mutateAsync(m.repo_id)));
toast.success(t('models.started_downloading', { count: missing.length }));
} catch (e) {
toast.error(t('models.install_failed', { message: e?.message || e }));
} finally {
setInstalling(false);
}
};
return (
<section
aria-labelledby="setup-summary-title"
data-testid="setup-summary"
className="mb-[32px] rounded-[14px] border border-border bg-card/40 px-[20px] py-[6px] font-sans"
>
<h2 id="setup-summary-title" className="sr-only">
{t('catalogue.summary_title')}
</h2>
<dl className="m-0 divide-y divide-border">
{rows.map((row) => (
<div
key={row.key}
data-testid={`setup-row-${row.key}`}
className="flex flex-wrap items-center gap-x-[16px] gap-y-[4px] py-[10px]"
>
<dt className="w-[128px] shrink-0 text-sm font-semibold text-foreground">
{row.label}
</dt>
<dd className="m-0 flex min-w-0 flex-1 flex-wrap items-center gap-x-[8px] text-sm text-muted-foreground">
{enginesQuery.isLoading && !engines && row.key !== 'dictation' ? (
<span>{t('common.loading')}</span>
) : (
<>
{row.name && (
<span
className="truncate text-foreground"
data-testid={`setup-name-${row.key}`}
>
{row.name}
</span>
)}
{row.device && (
<>
{row.name && <span aria-hidden="true">·</span>}
<span className="font-mono text-[11px] uppercase tracking-[0.03em]">
{row.device === 'remote' ? t('catalogue.device_remote') : row.device}
</span>
</>
)}
{(row.name || row.device) && <span aria-hidden="true">·</span>}
<Badge
tone={STATUS_TONE[row.status]}
size="xs"
data-testid={`setup-status-${row.key}`}
>
{statusLabel[row.status]}
</Badge>
</>
)}
</dd>
<Button
variant="subtle"
size="sm"
onClick={() => onChange?.(row.family)}
data-testid={`setup-change-${row.key}`}
>
{t('catalogue.change')}
</Button>
</div>
))}
</dl>
{reco && (
<div
data-testid="setup-reco"
className="flex flex-wrap items-center gap-x-[12px] gap-y-[6px] border-t border-border py-[10px] text-xs text-muted-foreground"
>
<span className="min-w-0 flex-1">
{reco.all_installed || missing.length === 0
? t('models.reco_installed_for', { device: reco.device?.label })
: t('catalogue.reco_line', {
installed: reco.models.length - missing.length,
total: reco.models.length,
device: reco.device?.label,
remaining: reco.download_gb_remaining,
})}
</span>
{missing.length > 0 && (
<Button
variant="primary"
size="sm"
onClick={installRest}
disabled={installing}
leading={
installing ? <RefreshCw size={11} className="spinner" /> : <Download size={11} />
}
data-testid="setup-install-rest"
>
{installing ? t('models.starting') : t('catalogue.install_rest')}
</Button>
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,208 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
let enginesData = null;
let recoData = null;
const installMutate = vi.fn().mockResolvedValue({});
vi.mock('../../api/hooks', () => ({
useEngines: () => ({ data: enginesData, isLoading: false }),
useRecommendations: () => ({ data: recoData }),
useInstallModel: () => ({ mutateAsync: installMutate }),
}));
vi.mock('../../api/client', () => ({ apiJson: vi.fn() }));
import { apiJson } from '../../api/client';
import { useAppStore } from '../../store';
import SetupSummary, { summarizeDictation, summarizeFamily } from './SetupSummary';
const t = i18n.t.bind(i18n);
const ENGINES = {
tts: {
active: 'indextts',
backends: [
{
id: 'indextts',
display_name: 'IndexTTS 2',
available: true,
effective_device: 'mps',
routing_status: 'accelerated',
},
],
},
asr: {
active: 'faster-whisper',
backends: [
{
id: 'faster-whisper',
display_name: 'Faster-Whisper',
available: true,
effective_device: 'cpu',
routing_status: 'cpu_fallback',
},
],
},
llm: { active: 'off', backends: [] },
};
const DICTATION = {
engine_available: true,
models: [
{ id: 'sherpa-whisper-tiny', label: 'Whisper Tiny', installed: true, recommended: true },
{ id: 'sherpa-parakeet-tdt-v3', label: 'Parakeet TDT v3', installed: false },
],
};
function mount(props = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<I18nextProvider i18n={i18n}>
<SetupSummary onChange={vi.fn()} {...props} />
</I18nextProvider>
</QueryClientProvider>,
);
}
describe('SetupSummary', () => {
beforeEach(() => {
vi.clearAllMocks();
enginesData = ENGINES;
recoData = null;
apiJson.mockResolvedValue(DICTATION);
useAppStore.setState({ dictationModelId: 'sherpa-whisper-tiny', dictationLoaded: true });
});
it('shows one line per capability: engine, device, and a plain status word', async () => {
mount();
const speech = screen.getByTestId('setup-row-speech');
expect(within(speech).getByTestId('setup-name-speech')).toHaveTextContent('IndexTTS 2');
expect(speech).toHaveTextContent('MPS');
expect(within(speech).getByTestId('setup-status-speech')).toHaveTextContent(
t('models.ready_badge'),
);
const asr = screen.getByTestId('setup-row-transcription');
expect(asr).toHaveTextContent('Faster-Whisper');
expect(within(asr).getByTestId('setup-status-transcription')).toHaveTextContent(
t('catalogue.status_cpu'),
);
const llm = screen.getByTestId('setup-row-llm');
expect(within(llm).getByTestId('setup-status-llm')).toHaveTextContent(
t('catalogue.status_off'),
);
expect(within(llm).queryByTestId('setup-name-llm')).toBeNull();
await waitFor(() =>
expect(screen.getByTestId('setup-name-dictation')).toHaveTextContent('Whisper Tiny'),
);
expect(screen.getByTestId('setup-status-dictation')).toHaveTextContent(t('models.ready_badge'));
});
it('flags an unavailable active engine and an undownloaded dictation model', async () => {
enginesData = {
...ENGINES,
tts: { active: 'gone', backends: [{ id: 'gone', display_name: 'Gone', available: false }] },
};
useAppStore.setState({ dictationModelId: 'sherpa-parakeet-tdt-v3' });
mount();
expect(screen.getByTestId('setup-status-speech')).toHaveTextContent(
t('catalogue.status_setup'),
);
await waitFor(() =>
expect(screen.getByTestId('setup-status-dictation')).toHaveTextContent(
t('models.not_installed'),
),
);
});
it('Change hands the rows engine family to the page (dictation lives under ASR)', () => {
const onChange = vi.fn();
mount({ onChange });
for (const [key, family] of [
['speech', 'tts'],
['transcription', 'asr'],
['dictation', 'asr'],
['llm', 'llm'],
]) {
fireEvent.click(screen.getByTestId(`setup-change-${key}`));
expect(onChange).toHaveBeenLastCalledWith(family);
}
});
it('summarises the recommended preset in one line and installs the rest in one click', async () => {
recoData = {
device: { label: 'Apple M2 Pro' },
all_installed: false,
download_gb_remaining: 2.4,
models: [
{ repo_id: 'a/have', installed: true, size_gb: 1 },
{ repo_id: 'b/need', installed: false, size_gb: 2.4 },
],
};
mount();
const line = screen.getByTestId('setup-reco');
expect(line).toHaveTextContent(
t('catalogue.reco_line', { installed: 1, total: 2, device: 'Apple M2 Pro', remaining: 2.4 }),
);
fireEvent.click(screen.getByTestId('setup-install-rest'));
await waitFor(() => expect(installMutate).toHaveBeenCalledWith('b/need'));
expect(installMutate).toHaveBeenCalledTimes(1);
});
it('drops the install button once the preset is complete', () => {
recoData = {
device: { label: 'Apple M2 Pro' },
all_installed: true,
total_gb: 3.4,
models: [{ repo_id: 'a/have', installed: true, size_gb: 1 }],
};
mount();
expect(screen.getByTestId('setup-reco')).toHaveTextContent(
t('models.reco_installed_for', { device: 'Apple M2 Pro' }),
);
expect(screen.queryByTestId('setup-install-rest')).toBeNull();
});
});
describe('summarizeFamily / summarizeDictation', () => {
it('maps routing and availability to the four status words', () => {
expect(summarizeFamily('tts', ENGINES.tts)).toEqual({
name: 'IndexTTS 2',
device: 'MPS',
status: 'ready',
});
expect(summarizeFamily('asr', ENGINES.asr).status).toBe('cpu');
expect(summarizeFamily('llm', ENGINES.llm)).toEqual({
name: null,
device: null,
status: 'off',
});
expect(summarizeFamily('tts', { active: 'x', backends: [] })).toEqual({
name: 'x',
device: null,
status: 'setup',
});
expect(
summarizeFamily('asr', {
active: 'openai-compat',
backends: [{ id: 'openai-compat', available: true, routing_status: 'n/a' }],
}).device,
).toBe('remote');
});
it('falls back to the recommended dictation model and reports engine trouble', () => {
expect(summarizeDictation(DICTATION, 'unknown').name).toBe('Whisper Tiny');
expect(summarizeDictation({ ...DICTATION, engine_available: false }, null).status).toBe(
'setup',
);
expect(summarizeDictation(null, null).status).toBe('setup');
});
});
@@ -1,49 +0,0 @@
import React from 'react';
import { Boxes } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store';
import { Button } from '../../ui';
import { SETTINGS_SECTION_SURFACE } from './primitives';
/**
* CataloguePointer the Settings-side signpost for what moved to the Model
* Catalogue workspace.
*
* Engine selection and the model store are now a top-level workspace
* (`pages/ModelCatalogue.jsx`), not Settings categories. The categories stay in
* the Settings IA on purpose: "Engines" / "Models" is where users (and every
* doc written before the move) look first, and search still has to find them.
* They render this instead of the panels, so the trip is one click, not a hunt.
*
* @param {'engines'|'models'} area which pane to open
*/
export default function CataloguePointer({ area = 'engines' }) {
const { t } = useTranslation();
const openCatalogue = useAppStore((s) => s.openCatalogue);
return (
<section
className={`${SETTINGS_SECTION_SURFACE} flex flex-wrap items-center gap-[var(--space-4)]`}
data-slot="settings-section"
data-testid={`catalogue-pointer-${area}`}
>
<span
className="inline-flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-[var(--chrome-radius-pill)] bg-[color-mix(in_srgb,var(--chrome-accent)_12%,var(--chrome-bg))] text-[color:var(--chrome-accent)]"
aria-hidden="true"
>
<Boxes size={18} />
</span>
<div className="min-w-0 flex-auto">
<h3 className="m-0 [font-family:var(--font-sans)] text-[length:var(--text-md)] font-semibold text-[color:var(--chrome-fg)]">
{t('catalogue.moved_title')}
</h3>
<p className="m-0 mt-[2px] [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[color:var(--chrome-fg-muted)]">
{t(area === 'models' ? 'catalogue.moved_models_body' : 'catalogue.moved_engines_body')}
</p>
</div>
<Button variant="primary" onClick={() => openCatalogue(area)}>
{t(area === 'models' ? 'catalogue.open_models' : 'catalogue.open_engines')}
</Button>
</section>
);
}
@@ -1,28 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { useAppStore } from '../../store';
import CataloguePointer from './CataloguePointer';
describe('CataloguePointer', () => {
beforeEach(() => {
act(() => {
useAppStore.setState({ mode: 'settings', pendingCatalogueTab: null });
});
});
it('sends the Engines category to the catalogue engines pane', () => {
render(<CataloguePointer area="engines" />);
fireEvent.click(screen.getByRole('button', { name: 'Open Engines' }));
expect(useAppStore.getState().mode).toBe('catalogue');
expect(useAppStore.getState().pendingCatalogueTab).toBe('engines');
});
it('sends the Models category to the catalogue models pane', () => {
render(<CataloguePointer area="models" />);
fireEvent.click(screen.getByRole('button', { name: 'Open Models' }));
expect(useAppStore.getState().mode).toBe('catalogue');
expect(useAppStore.getState().pendingCatalogueTab).toBe('models');
});
});
@@ -236,7 +236,7 @@ export default function LLMProvidersPanel() {
<button
type="button"
className="cursor-pointer border-0 bg-transparent p-0 text-[length:var(--text-xs)] text-[color:var(--chrome-accent)] underline-offset-2 hover:underline"
onClick={() => useAppStore.getState().openCatalogue?.({ pane: 'engines', family: 'llm' })}
onClick={() => useAppStore.getState().openCatalogue?.({ family: 'llm' })}
data-testid="llmp-open-catalogue"
>
{t('settings.llmp_catalogue_link')}
+102 -228
View File
@@ -1,15 +1,13 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { Cpu, RefreshCw, KeyRound } from 'lucide-react';
import { RefreshCw, Search, Zap } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { openExternal } from '../../api/external';
import { setupDownloadStreamUrl } from '../../api/setup';
import { listLoadedModels, unloadLoadedModel } from '../../api/system';
import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../../api/hooks';
import { Button } from '../../ui';
import { SettingsSection, SettingsInput, SETTINGS_SECTION_SURFACE } from './primitives';
import { Input } from '@/components/ui/input';
import { askConfirm } from './native';
import { fmtBytes } from './models/format';
import { computeRowRuntime } from './models/runtime';
import {
downloadKey,
@@ -18,19 +16,23 @@ import {
isAutoPurgeTerminal,
} from './models/downloadReducer';
import { makeModelColumns } from './models/columns';
import { groupModels } from './models/sections';
import { FAMILY_SECTIONS, groupModels, modelSectionKey, scopeReco } from './models/sections';
import RecoBanner from './models/RecoBanner';
import ModelSection from './models/ModelSection';
import VoicePreviewsPanel from './VoicePreviewsPanel';
/**
* Model store every known HF model, grouped by capability (TTS / ASR /
* Dictation / Diarisation), with install state and install / reinstall /
* delete per row. The curated "for your system" preset leads (RecoBanner);
* platform-incompatible rows collapse behind a per-section toggle. Per-model
* download progress is pulled from the shared /setup/download-stream SSE.
* Model store the downloadable weights for one engine family (or all of
* them when `family` is null), grouped by capability (TTS / ASR offline /
* Dictation streaming / Diarisation), with install state and install /
* reinstall / delete per row. The curated "for your system" preset leads
* (RecoBanner, scoped to the family); platform-incompatible rows collapse
* behind a per-section toggle. Per-model download progress is pulled from
* the shared /setup/download-stream SSE.
*
* Storage stats, the HF token and the voice-preview toggle used to sit on
* this list; they live in Settings Storage / Credentials now.
*/
export default function ModelStoreTab({ info, modelBadge, catalogueLayout = false }) {
export default function ModelStoreTab({ info, family = null }) {
const { t } = useTranslation();
// Role labels localized (diarization is an on-disk spelling alias for
// diarisation; both map to the same label).
@@ -51,6 +53,7 @@ export default function ModelStoreTab({ info, modelBadge, catalogueLayout = fals
const data = modelsQuery.data;
const loading = modelsQuery.isLoading;
const reco = recoQuery.data;
const familyReco = useMemo(() => scopeReco(reco, family), [reco, family]);
const installMutation = useInstallModel();
const deleteMutation = useDeleteModel();
@@ -83,36 +86,6 @@ export default function ModelStoreTab({ info, modelBadge, catalogueLayout = fals
return () => clearInterval(iv);
}, [hasActive]);
// HF token inline compact input in the toolbar
const [hfToken, setHfToken] = useState('');
const [hfSaved, setHfSaved] = useState(false);
const [hfSaving, setHfSaving] = useState(false);
const [hfExpanded, setHfExpanded] = useState(false);
const saveHfToken = async () => {
const value = hfToken.trim();
if (!value) return;
setHfSaving(true);
try {
// One canonical token path: persist to the encrypted app store the SAME
// store Settings Credentials writes AND clears (/api/settings/hf-token).
// This toolbar used to POST /system/set-env (env var + HF-CLI file), a
// *second* store the Credentials "Clear" button couldn't reach, so a
// toolbar-set token silently outlived a Clear (a support-ticket generator).
// Now both entry points share one store with one clear path.
const { apiPost } = await import('../../api/client');
await apiPost('/api/settings/hf-token', { token: value });
toast.success(t('models.hf_token_set_toast'));
setHfSaved(true);
setHfToken('');
setHfExpanded(false);
} catch (e) {
toast.error(t('settings.save_failed', { message: e.message }));
} finally {
setHfSaving(false);
}
};
const hfTokenSet = hfSaved || info?.has_hf_token;
// Open the progress stream once when the tab mounts; close on unmount.
useEffect(() => {
const es = new EventSource(setupDownloadStreamUrl());
@@ -297,8 +270,8 @@ export default function ModelStoreTab({ info, modelBadge, catalogueLayout = fals
);
const onInstallRecommended = async () => {
if (!reco) return;
const missing = reco.models.filter((m) => !m.installed);
if (!familyReco) return;
const missing = familyReco.models.filter((m) => !m.installed);
if (missing.length === 0) {
toast.success(t('models.recommended_installed'));
return;
@@ -316,7 +289,12 @@ export default function ModelStoreTab({ info, modelBadge, catalogueLayout = fals
}
};
const allModels = React.useMemo(() => data?.models || [], [data]);
// The family's slice of the catalog (all of it when unscoped).
const allModels = React.useMemo(() => {
const models = data?.models || [];
const keep = family ? FAMILY_SECTIONS[family] || [] : null;
return keep ? models.filter((m) => keep.includes(modelSectionKey(m))) : models;
}, [data, family]);
// Grouped catalog: TTS / ASR (offline transcription) / Dictation (streaming)
// / Diarisation, with the search query applied per-section (pure helper
// matches the same fields the old global filter did).
@@ -367,202 +345,98 @@ export default function ModelStoreTab({ info, modelBadge, catalogueLayout = fals
);
if (loading && !data) {
if (catalogueLayout) {
return (
<div className="px-[2px] py-[24px] font-sans text-[var(--text-md)] text-[var(--chrome-fg-dim)]">
{t('common.loading')}
</div>
);
}
return (
<SettingsSection icon={Cpu} title={t('settings.models')}>
<div className="settings-muted font-sans text-[var(--text-md)] text-[var(--chrome-fg-dim)]">
{t('common.loading')}
</div>
</SettingsSection>
<div className="px-[2px] py-[24px] font-sans text-sm text-muted-foreground">
{t('common.loading')}
</div>
);
}
if (!data) return null;
return (
<>
<section
className={
catalogueLayout
? 'flex min-h-0 flex-col'
: `${SETTINGS_SECTION_SURFACE} flex min-h-[95%] flex-col`
}
data-slot="settings-section"
data-testid="model-list-panel"
>
<div
className={`flex flex-wrap items-center justify-between gap-[var(--space-3)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] max-[580px]:flex-col max-[580px]:items-start ${
catalogueLayout
? 'mb-[24px] border-b border-[color-mix(in_srgb,var(--chrome-fg)_8%,transparent)] px-[2px] pb-[18px]'
: 'px-[2px] pb-[6px] pt-[2px]'
}`}
>
<div className="inline-flex flex-wrap items-center gap-[var(--space-2)]">
<span>
<strong className="font-semibold text-[var(--chrome-fg)]">
{fmtBytes(data.total_installed_bytes)}
</strong>
</span>
{data.disk_free_gb != null && (
<>
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span title={t('models.disk_free_title')}>
{t('models.disk_free', { size: `${data.disk_free_gb} GB` })}
</span>
</>
)}
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span title={data.hf_cache_dir}>
<code className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]">
{data.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~')}
</code>
</span>
{info && <span className="text-[var(--chrome-fg-dim)]">·</span>}
{info && <span>{modelBadge}</span>}
{info?.fast_download?.xet_enabled && (
<>
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span
className="text-[var(--chrome-accent)]"
title={
t('models.fast_download_title', {
version: info.fast_download.xet_version || 'Xet',
}) ||
`Fast downloads via Xet ${info.fast_download.xet_version || ''} — parallel chunked transfer`
}
>
{t('models.fast_download_badge') || 'fast download'}
</span>
</>
)}
</div>
<div className="inline-flex items-center gap-[var(--space-2)]">
{/* Compact HF token inline */}
{!hfTokenSet && !hfExpanded && (
<button
className="inline-flex cursor-pointer items-center gap-1 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-transparent px-[var(--space-2)] py-[2px] text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
onClick={() => setHfExpanded(true)}
title={t('models.hf_set_title')}
>
<KeyRound size={11} /> {t('models.hf_token_btn')}
</button>
)}
{!hfTokenSet && hfExpanded && (
<div className="inline-flex items-center gap-[var(--space-2)]">
<input
type="password"
className="min-w-0 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-2)] py-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
placeholder="hf_xxxxxxxxxxxx"
value={hfToken}
onChange={(e) => setHfToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveHfToken();
if (e.key === 'Escape') setHfExpanded(false);
}}
autoFocus
/>
<Button
size="sm"
variant="subtle"
onClick={saveHfToken}
disabled={hfSaving || !hfToken.trim()}
loading={hfSaving}
>
{t('common.save')}
</Button>
<a
href="#"
className="text-[var(--chrome-accent)] no-underline hover:underline"
onClick={(e) => {
e.preventDefault();
openExternal('https://huggingface.co/settings/tokens');
}}
title="Open huggingface.co/settings/tokens"
>
{t('models.get_token')}
</a>
</div>
)}
{hfTokenSet && (
<span className="inline-flex items-center gap-1 text-[var(--chrome-severity-ok)]">
<KeyRound size={10} />
</span>
)}
<Button
variant="subtle"
size="sm"
onClick={reload}
loading={loading}
leading={<RefreshCw size={11} />}
>
{t('common.refresh')}
</Button>
</div>
</div>
<RecoBanner
reco={reco}
t={t}
installMutation={installMutation}
installingReco={installingReco}
setInstallingReco={setInstallingReco}
onInstallRecommended={onInstallRecommended}
onInstall={onInstall}
getRowRuntime={getRowRuntime}
diskFreeGb={data.disk_free_gb}
/>
<div
className={`${catalogueLayout ? 'my-[24px]' : 'my-[var(--space-2)]'} flex items-center gap-[var(--space-2)] max-[580px]:flex-col max-[580px]:items-stretch`}
>
<SettingsInput
<section className="flex min-h-0 flex-col font-sans" data-testid="model-list-panel">
<div className="mb-[18px] flex flex-wrap items-center gap-[10px] px-[2px]">
<div className="relative w-full min-w-[160px] max-w-[420px]">
<Search
size={13}
className="pointer-events-none absolute left-[10px] top-1/2 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
className="max-w-none flex-1 text-[length:var(--text-xs)] min-w-[120px]"
className="h-8 pl-[30px] text-sm"
placeholder={t('models.search_placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t('models.search_label')}
/>
</div>
<div className="min-h-0 flex-1" data-testid="model-list-area">
{sections.map((group) => (
<ModelSection
key={group.key}
sectionKey={group.key}
title={MODEL_SECTION_LABEL[group.key] || group.key}
group={group}
columns={columns}
getRowRuntime={getRowRuntime}
t={t}
/>
))}
</div>
{/* Global empty state every section filtered out. Same actionable
"Clear filters" affordance the table-level empty state used to carry. */}
{sections.length === 0 && allModels.length > 0 && (
<div className="models-table__empty">
<span>{t('models.no_matches')}</span>
<Button
size="sm"
variant="subtle"
className="ml-[8px]"
onClick={() => setQuery('')}
data-testid="models-clear-filters"
>
{t('models.clear_filters')}
</Button>
</div>
{info?.fast_download?.xet_enabled && (
<span
className="inline-flex items-center gap-1 font-mono text-[11px] text-accent"
title={
t('models.fast_download_title', {
version: info.fast_download.xet_version || 'Xet',
}) ||
`Fast downloads via Xet ${info.fast_download.xet_version || ''} — parallel chunked transfer`
}
>
<Zap size={11} aria-hidden="true" />{' '}
{t('models.fast_download_badge') || 'fast download'}
</span>
)}
</section>
{/* Downloaded previews belong next to downloaded models: same question
("what has this install fetched?"), same tab. */}
<VoicePreviewsPanel />
</>
<span className="flex-1" />
<Button
variant="subtle"
size="sm"
onClick={reload}
loading={loading}
leading={<RefreshCw size={11} />}
>
{t('common.refresh')}
</Button>
</div>
<RecoBanner
reco={familyReco}
t={t}
installMutation={installMutation}
installingReco={installingReco}
setInstallingReco={setInstallingReco}
onInstallRecommended={onInstallRecommended}
onInstall={onInstall}
getRowRuntime={getRowRuntime}
diskFreeGb={data.disk_free_gb}
/>
<div className="min-h-0 flex-1" data-testid="model-list-area">
{sections.map((group) => (
<ModelSection
key={group.key}
sectionKey={group.key}
title={MODEL_SECTION_LABEL[group.key] || group.key}
group={group}
columns={columns}
getRowRuntime={getRowRuntime}
t={t}
/>
))}
</div>
{/* Global empty state every section filtered out. Same actionable
"Clear filters" affordance the table-level empty state carries. */}
{sections.length === 0 && allModels.length > 0 && (
<div className="flex items-center gap-2 px-[2px] py-[18px] text-sm text-muted-foreground">
<span>{t('models.no_matches')}</span>
<Button
size="sm"
variant="subtle"
onClick={() => setQuery('')}
data-testid="models-clear-filters"
>
{t('models.clear_filters')}
</Button>
</div>
)}
</section>
);
}
@@ -77,7 +77,7 @@ describe('SettingsSidebar — keyboard navigation', () => {
it('is a single tab stop: only the active category is tabbable', () => {
render(<SettingsSidebar active="appearance" onSelect={() => {}} />);
expect(screen.getByTestId('settings-nav-appearance')).toHaveAttribute('tabindex', '0');
expect(screen.getByTestId('settings-nav-engines')).toHaveAttribute('tabindex', '-1');
expect(screen.getByTestId('settings-nav-dictation')).toHaveAttribute('tabindex', '-1');
});
it('ArrowDown/ArrowUp move selection, crossing group boundaries', () => {
@@ -86,12 +86,12 @@ describe('SettingsSidebar — keyboard navigation', () => {
// the first item of Voice & Engines arrowing must not stop at the seam.
render(<SettingsSidebar active="appearance" onSelect={onSelect} />);
fireEvent.keyDown(nav(), { key: 'ArrowDown' });
expect(onSelect).toHaveBeenCalledWith('engines');
expect(onSelect).toHaveBeenCalledWith('dictation');
onSelect.mockClear();
render(<SettingsSidebar active="models" onSelect={onSelect} />);
render(<SettingsSidebar active="pronunciation" onSelect={onSelect} />);
fireEvent.keyDown(screen.getAllByTestId('settings-nav-scroll')[1], { key: 'ArrowUp' });
expect(onSelect).toHaveBeenCalledWith('engines');
expect(onSelect).toHaveBeenCalledWith('dictation');
});
it('Home/End jump to the first and last visible category', () => {
@@ -132,7 +132,7 @@ function SmallButton({ children, onClick, title, testId, disabled }) {
export default function StorageUsagePanel() {
const { t } = useTranslation();
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
const openCatalogue = useAppStore((s) => s.openCatalogue);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -379,7 +379,7 @@ export default function StorageUsagePanel() {
</span>
{cat.id === 'hf_cache' && (
<SmallButton
onClick={() => openSettingsTab('models')}
onClick={() => openCatalogue()}
title={t('settings.storage_manage_models_hint', {
defaultValue: 'Reclaim space by removing models in the Model Store',
})}
@@ -68,3 +68,31 @@ export function groupModels(models, query) {
};
});
}
/** Which catalog sections belong to each engine family (one family's slice of the catalog). */
export const FAMILY_SECTIONS = {
tts: ['tts'],
asr: ['asr', 'dictation', 'diarisation'],
llm: [],
};
/**
* Pure: narrow the device-wide recommendation preset to one family so the
* banner under a family's engines only talks about that family's weights.
* Totals are recomputed from the subset; null when nothing applies.
*/
export function scopeReco(reco, family) {
if (!reco || !family) return reco;
const keep = FAMILY_SECTIONS[family] || [];
const models = (reco.models || []).filter((m) => keep.includes(modelSectionKey(m)));
if (models.length === 0) return null;
const missing = models.filter((m) => !m.installed);
const gb = (xs) => Math.round(xs.reduce((sum, m) => sum + (m.size_gb || 0), 0) * 10) / 10;
return {
...reco,
models,
all_installed: missing.length === 0,
total_gb: gb(models),
download_gb_remaining: gb(missing),
};
}
@@ -90,49 +90,6 @@ export const GROUPS = [
labelKey: 'settings.group_voice',
defaultLabel: 'Voice & Engines',
items: [
{
id: 'engines',
labelKey: 'settings.engines',
defaultLabel: 'Engines',
icon: Plug,
// Engine selection lives in the Model Catalogue workspace now; the
// category stays so the old name still finds it (it renders a pointer).
keywords: [
'engine',
'catalogue',
'catalog',
'model catalogue',
'default engine',
'tts engine',
'indextts',
'cosyvoice',
'compatibility',
'gpu',
'asr',
'transcription',
'whisper',
'openai-compatible',
'remote asr',
],
},
{
id: 'models',
labelKey: 'settings.models',
defaultLabel: 'Models',
icon: Cpu,
restart: true,
keywords: [
'model',
'download',
'cache directory',
'models directory',
'hugging face mirror',
'hf_endpoint',
'catalogue',
'catalog',
'model store',
],
},
{
id: 'dictation',
labelKey: 'settings.dictation',
@@ -231,6 +188,8 @@ export const GROUPS = [
},
{
id: 'storage',
// Hosts a restart-bound panel (models directory / HF mirror).
restart: true,
labelKey: 'settings.storage',
defaultLabel: 'Storage',
icon: HardDrive,
@@ -251,6 +210,13 @@ export const GROUPS = [
'free space',
'disk space',
'model cache size',
'models directory',
'cache directory',
'model store',
'catalogue',
'catalog',
'download',
'voice previews',
'engine venvs',
'temp files',
'clear logs',
@@ -279,6 +245,8 @@ export const GROUPS = [
},
{
id: 'network',
// Hosts a restart-bound panel (models directory / HF mirror).
restart: true,
labelKey: 'settings.network',
defaultLabel: 'Network',
icon: Wifi,
@@ -297,6 +265,9 @@ export const GROUPS = [
// settingsCategories.test.jsx like Models / Performance / Sharing.
restart: true,
keywords: [
'hugging face mirror',
'hf_endpoint',
'mirror',
'ffmpeg',
'ffprobe',
'ffmpeg path',
@@ -62,8 +62,8 @@ describe('restart flag ↔ RestartBadge lockstep', () => {
// restart: true, or the sidebar glyph / header badge contract breaks
// (that drift is exactly how Network shipped without its glyph).
const PANEL_CATEGORY = {
'StoragePanel.jsx': 'models',
'HFMirrorPanel.jsx': 'models',
'StoragePanel.jsx': 'storage',
'HFMirrorPanel.jsx': 'network',
'RemoteBackendPanel.jsx': 'sharing',
'AudioToolsPanel.jsx': 'audio-tools',
'PerformancePanel.jsx': 'performance',
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "كتالوج النماذج",
"tab_engines": "المحركات",
"tab_models": "النماذج",
"moved_title": "أصبح ضمن كتالوج النماذج",
"moved_engines_body": "انتقل اختيار المحرك الافتراضي للنطق والتفريغ النصي ونماذج اللغة الكبيرة إلى مساحة عمل كتالوج النماذج.",
"moved_models_body": "انتقل تصفح النماذج المنزَّلة وتثبيتها وإزالتها إلى مساحة عمل كتالوج النماذج.",
"open_engines": "فتح المحركات",
"open_models": "فتح النماذج"
"summary_title": "إعدادك",
"change": "تغيير",
"status_setup": "يحتاج إلى إعداد",
"status_cpu": "احتياطي CPU",
"status_off": "متوقف",
"weights": "الأوزان المنزَّلة",
"storage_link": "إعدادات التخزين",
"reco_line": "تم تثبيت {{installed}} من {{total}} نماذج موصى بها لـ {{device}} · يتبقى {{remaining}} GB",
"install_rest": "تثبيت الباقي",
"device_remote": "بعيد"
},
"settings": {
"remote_backend_test": "اختبار الاتصال",
@@ -779,7 +782,7 @@
"configureProviders": "إعداد مقدمي الخدمة",
"loading": "جارٍ تحميل المحركات…",
"refresh": "تحديث",
"matrixTitle": "مصفوفة توافق المحرك",
"matrixTitle": "المحركات",
"loadFailed": "فشل تحميل المحركات: {{message}}",
"couldNotLoad": "لا يمكن تحميل المحركات: {{message}}",
"retry": "أعد المحاولة",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Modellkatalog",
"tab_engines": "Engines",
"tab_models": "Modelle",
"moved_title": "Jetzt im Modellkatalog",
"moved_engines_body": "Die Auswahl der Standard-Engine für Sprache, Transkription und LLM befindet sich jetzt im Arbeitsbereich „Modellkatalog“.",
"moved_models_body": "Heruntergeladene Modelle durchsehen, installieren und entfernen befindet sich jetzt im Arbeitsbereich „Modellkatalog“.",
"open_engines": "Engines öffnen",
"open_models": "Modelle öffnen"
"summary_title": "Deine Einrichtung",
"change": "Ändern",
"status_setup": "Einrichtung nötig",
"status_cpu": "CPU-Fallback",
"status_off": "Aus",
"weights": "Heruntergeladene Gewichte",
"storage_link": "Speichereinstellungen",
"reco_line": "{{installed}} von {{total}} empfohlenen Modellen für {{device}} installiert · noch {{remaining}} GB",
"install_rest": "Rest installieren",
"device_remote": "Remote"
},
"settings": {
"remote_backend_test": "Verbindung testen",
@@ -779,7 +782,7 @@
"configureProviders": "Anbieter konfigurieren",
"loading": "Motoren werden geladen…",
"refresh": "Aktualisieren",
"matrixTitle": "Engine-Kompatibilitätsmatrix",
"matrixTitle": "Engines",
"loadFailed": "Fehler beim Laden der Engines: {{message}}",
"couldNotLoad": "Engines konnten nicht geladen werden: {{message}}",
"retry": "Versuchen Sie es noch einmal",
+11 -8
View File
@@ -171,13 +171,16 @@
},
"catalogue": {
"title": "Model Catalogue",
"tab_engines": "Engines",
"tab_models": "Models",
"moved_title": "Now in the Model Catalogue",
"moved_engines_body": "Choosing the default speech, transcription and LLM engine moved to the Model Catalogue workspace.",
"moved_models_body": "Browsing, installing and removing model downloads moved to the Model Catalogue workspace.",
"open_engines": "Open Engines",
"open_models": "Open Models"
"summary_title": "Your setup",
"change": "Change",
"status_setup": "Needs setup",
"status_cpu": "CPU fallback",
"status_off": "Off",
"weights": "Downloaded weights",
"storage_link": "Storage settings",
"reco_line": "{{installed}} of {{total}} recommended models for {{device}} installed · {{remaining}} GB to go",
"install_rest": "Install the rest",
"device_remote": "Remote"
},
"audiobook": {
"cast": "Cast",
@@ -2118,7 +2121,7 @@
"configureProviders": "Configure providers",
"loading": "Loading engines…",
"refresh": "Refresh",
"matrixTitle": "Engine Compatibility Matrix",
"matrixTitle": "Engines",
"familyMatrixTitle": "{{family}} Engines",
"colEngine": "Engine",
"colGpuCompat": "GPU compat",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Catálogo de modelos",
"tab_engines": "Motores",
"tab_models": "Modelos",
"moved_title": "Ahora en el catálogo de modelos",
"moved_engines_body": "Elegir el motor predeterminado de voz, transcripción y LLM se movió al espacio Catálogo de modelos.",
"moved_models_body": "Explorar, instalar y eliminar descargas de modelos se movió al espacio Catálogo de modelos.",
"open_engines": "Abrir motores",
"open_models": "Abrir modelos"
"summary_title": "Tu configuración",
"change": "Cambiar",
"status_setup": "Requiere configuración",
"status_cpu": "Alternativa CPU",
"status_off": "Desactivado",
"weights": "Pesos descargados",
"storage_link": "Ajustes de almacenamiento",
"reco_line": "{{installed}} de {{total}} modelos recomendados para {{device}} instalados · faltan {{remaining}} GB",
"install_rest": "Instalar el resto",
"device_remote": "Remoto"
},
"settings": {
"remote_backend_test": "Probar conexión",
@@ -779,7 +782,7 @@
"configureProviders": "Configurar proveedores",
"loading": "Cargando motores…",
"refresh": "Actualizar",
"matrixTitle": "Matriz de compatibilidad de motores",
"matrixTitle": "Motores",
"loadFailed": "No se pudieron cargar los motores: {{message}}",
"couldNotLoad": "No se pudieron cargar los motores: {{message}}",
"retry": "Reintentar",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Catalogue de modèles",
"tab_engines": "Moteurs",
"tab_models": "Modèles",
"moved_title": "Désormais dans le catalogue de modèles",
"moved_engines_body": "Le choix du moteur par défaut pour la voix, la transcription et les LLM se trouve maintenant dans lespace Catalogue de modèles.",
"moved_models_body": "Parcourir, installer et supprimer les modèles téléchargés se trouve maintenant dans lespace Catalogue de modèles.",
"open_engines": "Ouvrir les moteurs",
"open_models": "Ouvrir les modèles"
"summary_title": "Votre configuration",
"change": "Modifier",
"status_setup": "Configuration requise",
"status_cpu": "Repli CPU",
"status_off": "Désactivé",
"weights": "Poids téléchargés",
"storage_link": "Paramètres de stockage",
"reco_line": "{{installed}} des {{total}} modèles recommandés pour {{device}} installés · {{remaining}} Go restants",
"install_rest": "Installer le reste",
"device_remote": "Distant"
},
"settings": {
"remote_backend_test": "Tester la connexion",
@@ -779,7 +782,7 @@
"configureProviders": "Configurer les fournisseurs",
"loading": "Chargement des moteurs…",
"refresh": "Actualiser",
"matrixTitle": "Matrice de compatibilité des moteurs",
"matrixTitle": "Moteurs",
"loadFailed": "Échec du chargement des moteurs : {{message}}",
"couldNotLoad": "Impossible de charger les moteurs : {{message}}",
"retry": "Réessayer",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "मॉडल कैटलॉग",
"tab_engines": "इंजन",
"tab_models": "मॉडल",
"moved_title": "अब मॉडल कैटलॉग में",
"moved_engines_body": "वाक्, प्रतिलेखन और LLM का डिफ़ॉल्ट इंजन चुनना अब मॉडल कैटलॉग वर्कस्पेस में है।",
"moved_models_body": "डाउनलोड किए गए मॉडल देखना, इंस्टॉल करना और हटाना अब मॉडल कैटलॉग वर्कस्पेस में है।",
"open_engines": "इंजन खोलें",
"open_models": "मॉडल खोलें"
"summary_title": "आपका सेटअप",
"change": "बदलें",
"status_setup": "सेटअप आवश्यक",
"status_cpu": "CPU फ़ॉलबैक",
"status_off": "बंद",
"weights": "डाउनलोड किए गए वेट्स",
"storage_link": "स्टोरेज सेटिंग्स",
"reco_line": "{{device}} के लिए {{total}} में से {{installed}} अनुशंसित मॉडल इंस्टॉल · {{remaining}} GB शेष",
"install_rest": "बाकी इंस्टॉल करें",
"device_remote": "रिमोट"
},
"settings": {
"remote_backend_test": "कनेक्शन जाँचें",
@@ -779,7 +782,7 @@
"configureProviders": "प्रदाता कॉन्फ़िगर करें",
"loading": "इंजन लोड हो रहे हैं...",
"refresh": "ताज़ा करें",
"matrixTitle": "इंजन अनुकूलता मैट्रिक्स",
"matrixTitle": "इंजन",
"loadFailed": "इंजन लोड करने में विफल: {{message}}",
"couldNotLoad": "इंजन लोड नहीं हो सका: {{message}}",
"retry": "पुनः प्रयास करें",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Katalog model",
"tab_engines": "Engine",
"tab_models": "Model",
"moved_title": "Kini di Katalog model",
"moved_engines_body": "Pemilihan engine default untuk suara, transkripsi, dan LLM kini ada di ruang kerja Katalog model.",
"moved_models_body": "Menjelajahi, memasang, dan menghapus model yang diunduh kini ada di ruang kerja Katalog model.",
"open_engines": "Buka engine",
"open_models": "Buka model"
"summary_title": "Pengaturan Anda",
"change": "Ubah",
"status_setup": "Perlu penyiapan",
"status_cpu": "Fallback CPU",
"status_off": "Nonaktif",
"weights": "Bobot yang diunduh",
"storage_link": "Pengaturan penyimpanan",
"reco_line": "{{installed}} dari {{total}} model yang direkomendasikan untuk {{device}} terpasang · sisa {{remaining}} GB",
"install_rest": "Pasang sisanya",
"device_remote": "Jarak jauh"
},
"settings": {
"remote_backend_test": "Uji koneksi",
@@ -779,7 +782,7 @@
"configureProviders": "Konfigurasikan penyedia",
"loading": "Memuat mesin…",
"refresh": "Segarkan",
"matrixTitle": "Matriks Kompatibilitas Mesin",
"matrixTitle": "Mesin",
"loadFailed": "Gagal memuat mesin: {{message}}",
"couldNotLoad": "Tidak dapat memuat mesin: {{message}}",
"retry": "Coba lagi",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Catalogo modelli",
"tab_engines": "Motori",
"tab_models": "Modelli",
"moved_title": "Ora nel catalogo modelli",
"moved_engines_body": "La scelta del motore predefinito per voce, trascrizione e LLM si trova ora nello spazio Catalogo modelli.",
"moved_models_body": "Sfogliare, installare e rimuovere i modelli scaricati si trova ora nello spazio Catalogo modelli.",
"open_engines": "Apri motori",
"open_models": "Apri modelli"
"summary_title": "La tua configurazione",
"change": "Cambia",
"status_setup": "Da configurare",
"status_cpu": "Fallback CPU",
"status_off": "Disattivato",
"weights": "Pesi scaricati",
"storage_link": "Impostazioni archiviazione",
"reco_line": "{{installed}} di {{total}} modelli consigliati per {{device}} installati · mancano {{remaining}} GB",
"install_rest": "Installa il resto",
"device_remote": "Remoto"
},
"settings": {
"remote_backend_test": "Verifica connessione",
@@ -779,7 +782,7 @@
"configureProviders": "Configura i provider",
"loading": "Caricamento motori…",
"refresh": "Aggiorna",
"matrixTitle": "Matrice di compatibilità del motore",
"matrixTitle": "Motori",
"loadFailed": "Impossibile caricare i motori: {{message}}",
"couldNotLoad": "Impossibile caricare i motori: {{message}}",
"retry": "Riprova",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "モデルカタログ",
"tab_engines": "エンジン",
"tab_models": "モデル",
"moved_title": "モデルカタログに移動しました",
"moved_engines_body": "音声・文字起こし・LLM の既定エンジンの選択は「モデルカタログ」ワークスペースに移動しました。",
"moved_models_body": "モデルの閲覧・インストール・削除は「モデルカタログ」ワークスペースに移動しました。",
"open_engines": "エンジンを開く",
"open_models": "モデルを開く"
"summary_title": "現在の設定",
"change": "変更",
"status_setup": "設定が必要",
"status_cpu": "CPUフォールバック",
"status_off": "オフ",
"weights": "ダウンロード済みの重み",
"storage_link": "ストレージ設定",
"reco_line": "{{device}} 向けの推奨モデル {{total}} 件中 {{installed}} 件をインストール済み · 残り {{remaining}} GB",
"install_rest": "残りをインストール",
"device_remote": "リモート"
},
"settings": {
"remote_backend_test": "接続をテスト",
@@ -779,7 +782,7 @@
"configureProviders": "プロバイダーを設定",
"loading": "エンジンをロード中…",
"refresh": "リフレッシュ",
"matrixTitle": "エンジン互換性マトリックス",
"matrixTitle": "エンジン",
"loadFailed": "エンジンのロードに失敗しました: {{message}}",
"couldNotLoad": "エンジンをロードできませんでした: {{message}}",
"retry": "再試行",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "모델 카탈로그",
"tab_engines": "엔진",
"tab_models": "모델",
"moved_title": "이제 모델 카탈로그에 있습니다",
"moved_engines_body": "음성, 전사, LLM 기본 엔진 선택이 모델 카탈로그 작업 공간으로 이동했습니다.",
"moved_models_body": "내려받은 모델을 살펴보고 설치·삭제하는 기능이 모델 카탈로그 작업 공간으로 이동했습니다.",
"open_engines": "엔진 열기",
"open_models": "모델 열기"
"summary_title": "내 설정",
"change": "변경",
"status_setup": "설정 필요",
"status_cpu": "CPU 대체",
"status_off": "꺼짐",
"weights": "다운로드한 가중치",
"storage_link": "저장소 설정",
"reco_line": "{{device}} 권장 모델 {{total}}개 중 {{installed}}개 설치됨 · {{remaining}} GB 남음",
"install_rest": "나머지 설치",
"device_remote": "원격"
},
"settings": {
"remote_backend_test": "연결 테스트",
@@ -1023,7 +1026,7 @@
"configureProviders": "제공업체 설정",
"loading": "엔진 로드 중…",
"refresh": "새로고침",
"matrixTitle": "엔진 호환성 매트릭스",
"matrixTitle": "엔진",
"loadFailed": "엔진을 로드하지 못했습니다: {{message}}",
"couldNotLoad": "엔진을 로드할 수 없습니다: {{message}}",
"retry": "재시도",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Modelcatalogus",
"tab_engines": "Engines",
"tab_models": "Modellen",
"moved_title": "Nu in de modelcatalogus",
"moved_engines_body": "Het kiezen van de standaard-engine voor spraak, transcriptie en LLM staat nu in de werkruimte Modelcatalogus.",
"moved_models_body": "Gedownloade modellen bekijken, installeren en verwijderen staat nu in de werkruimte Modelcatalogus.",
"open_engines": "Engines openen",
"open_models": "Modellen openen"
"summary_title": "Jouw configuratie",
"change": "Wijzigen",
"status_setup": "Instellen vereist",
"status_cpu": "CPU-fallback",
"status_off": "Uit",
"weights": "Gedownloade gewichten",
"storage_link": "Opslaginstellingen",
"reco_line": "{{installed}} van {{total}} aanbevolen modellen voor {{device}} geïnstalleerd · nog {{remaining}} GB",
"install_rest": "Rest installeren",
"device_remote": "Extern"
},
"settings": {
"remote_backend_test": "Verbinding testen",
@@ -779,7 +782,7 @@
"configureProviders": "Providers configureren",
"loading": "Motoren laden…",
"refresh": "Vernieuwen",
"matrixTitle": "Motorcompatibiliteitsmatrix",
"matrixTitle": "Motoren",
"loadFailed": "Kan motoren niet laden: {{message}}",
"couldNotLoad": "Kan motoren niet laden: {{message}}",
"retry": "Opnieuw proberen",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Katalog modeli",
"tab_engines": "Silniki",
"tab_models": "Modele",
"moved_title": "Teraz w katalogu modeli",
"moved_engines_body": "Wybór domyślnego silnika mowy, transkrypcji i LLM znajduje się teraz w obszarze Katalog modeli.",
"moved_models_body": "Przeglądanie, instalowanie i usuwanie pobranych modeli znajduje się teraz w obszarze Katalog modeli.",
"open_engines": "Otwórz silniki",
"open_models": "Otwórz modele"
"summary_title": "Twoja konfiguracja",
"change": "Zmień",
"status_setup": "Wymaga konfiguracji",
"status_cpu": "Tryb awaryjny CPU",
"status_off": "Wyłączone",
"weights": "Pobrane wagi",
"storage_link": "Ustawienia pamięci",
"reco_line": "Zainstalowano {{installed}} z {{total}} zalecanych modeli dla {{device}} · pozostało {{remaining}} GB",
"install_rest": "Zainstaluj resztę",
"device_remote": "Zdalny"
},
"settings": {
"remote_backend_test": "Testuj połączenie",
@@ -779,7 +782,7 @@
"configureProviders": "Skonfiguruj dostawców",
"loading": "Ładowanie silników…",
"refresh": "Odśwież",
"matrixTitle": "Matryca kompatybilności silników",
"matrixTitle": "Silniki",
"loadFailed": "Nie udało się załadować silników: {{message}}",
"couldNotLoad": "Nie można załadować silników: {{message}}",
"retry": "Spróbuj ponownie",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Catálogo de modelos",
"tab_engines": "Motores",
"tab_models": "Modelos",
"moved_title": "Agora no catálogo de modelos",
"moved_engines_body": "A escolha do motor padrão de voz, transcrição e LLM foi para o espaço Catálogo de modelos.",
"moved_models_body": "Explorar, instalar e remover modelos baixados foi para o espaço Catálogo de modelos.",
"open_engines": "Abrir motores",
"open_models": "Abrir modelos"
"summary_title": "Sua configuração",
"change": "Alterar",
"status_setup": "Requer configuração",
"status_cpu": "Alternativa CPU",
"status_off": "Desligado",
"weights": "Pesos baixados",
"storage_link": "Configurações de armazenamento",
"reco_line": "{{installed}} de {{total}} modelos recomendados para {{device}} instalados · faltam {{remaining}} GB",
"install_rest": "Instalar o restante",
"device_remote": "Remoto"
},
"settings": {
"remote_backend_test": "Testar conexão",
@@ -779,7 +782,7 @@
"configureProviders": "Configurar provedores",
"loading": "Carregando motores…",
"refresh": "Atualizar",
"matrixTitle": "Matriz de compatibilidade do motor",
"matrixTitle": "Motores",
"loadFailed": "Falha ao carregar motores: {{message}}",
"couldNotLoad": "Não foi possível carregar os motores: {{message}}",
"retry": "Tentar novamente",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Каталог моделей",
"tab_engines": "Движки",
"tab_models": "Модели",
"moved_title": "Теперь в каталоге моделей",
"moved_engines_body": "Выбор движка по умолчанию для синтеза речи, расшифровки и LLM перенесён в раздел «Каталог моделей».",
"moved_models_body": "Просмотр, установка и удаление загруженных моделей перенесены в раздел «Каталог моделей».",
"open_engines": "Открыть движки",
"open_models": "Открыть модели"
"summary_title": "Ваша конфигурация",
"change": "Изменить",
"status_setup": "Требуется настройка",
"status_cpu": "Резерв на CPU",
"status_off": "Выкл.",
"weights": "Загруженные веса",
"storage_link": "Настройки хранилища",
"reco_line": "Установлено {{installed}} из {{total}} рекомендуемых моделей для {{device}} · осталось {{remaining}} ГБ",
"install_rest": "Установить остальное",
"device_remote": "Удалённый"
},
"settings": {
"remote_backend_test": "Проверить подключение",
@@ -779,7 +782,7 @@
"configureProviders": "Настроить поставщиков",
"loading": "Загрузка двигателей…",
"refresh": "Обновить",
"matrixTitle": "Матрица совместимости двигателей",
"matrixTitle": "Движки",
"loadFailed": "Не удалось загрузить двигатели: {{message}}.",
"couldNotLoad": "Не удалось загрузить двигатели: {{message}}",
"retry": "Повторить попытку",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Modellkatalog",
"tab_engines": "Motorer",
"tab_models": "Modeller",
"moved_title": "Nu i modellkatalogen",
"moved_engines_body": "Att välja standardmotor för tal, transkribering och LLM finns nu i arbetsytan Modellkatalog.",
"moved_models_body": "Att bläddra bland, installera och ta bort nedladdade modeller finns nu i arbetsytan Modellkatalog.",
"open_engines": "Öppna motorer",
"open_models": "Öppna modeller"
"summary_title": "Din konfiguration",
"change": "Ändra",
"status_setup": "Behöver konfigureras",
"status_cpu": "CPU-reserv",
"status_off": "Av",
"weights": "Nedladdade vikter",
"storage_link": "Lagringsinställningar",
"reco_line": "{{installed}} av {{total}} rekommenderade modeller för {{device}} installerade · {{remaining}} GB kvar",
"install_rest": "Installera resten",
"device_remote": "Fjärr"
},
"settings": {
"remote_backend_test": "Testa anslutningen",
@@ -779,7 +782,7 @@
"configureProviders": "Konfigurera leverantörer",
"loading": "Laddar motorer...",
"refresh": "Uppdatera",
"matrixTitle": "Motorkompatibilitetsmatris",
"matrixTitle": "Motorer",
"loadFailed": "Det gick inte att ladda motorer: {{message}}",
"couldNotLoad": "Kunde inte ladda motorer: {{message}}",
"retry": "Försök igen",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "แค็ตตาล็อกโมเดล",
"tab_engines": "เอนจิน",
"tab_models": "โมเดล",
"moved_title": "ย้ายไปที่แค็ตตาล็อกโมเดลแล้ว",
"moved_engines_body": "การเลือกเอนจินเริ่มต้นสำหรับเสียงพูด การถอดเสียง และ LLM ย้ายไปอยู่ในพื้นที่ทำงานแค็ตตาล็อกโมเดลแล้ว",
"moved_models_body": "การเรียกดู ติดตั้ง และลบโมเดลที่ดาวน์โหลดไว้ ย้ายไปอยู่ในพื้นที่ทำงานแค็ตตาล็อกโมเดลแล้ว",
"open_engines": "เปิดเอนจิน",
"open_models": "เปิดโมเดล"
"summary_title": "การตั้งค่าของคุณ",
"change": "เปลี่ยน",
"status_setup": "ต้องตั้งค่า",
"status_cpu": "สำรองด้วย CPU",
"status_off": "ปิด",
"weights": "น้ำหนักโมเดลที่ดาวน์โหลด",
"storage_link": "การตั้งค่าพื้นที่เก็บข้อมูล",
"reco_line": "ติดตั้งแล้ว {{installed}} จาก {{total}} โมเดลที่แนะนำสำหรับ {{device}} · เหลืออีก {{remaining}} GB",
"install_rest": "ติดตั้งส่วนที่เหลือ",
"device_remote": "ระยะไกล"
},
"settings": {
"remote_backend_test": "ทดสอบการเชื่อมต่อ",
@@ -779,7 +782,7 @@
"configureProviders": "ตั้งค่าผู้ให้บริการ",
"loading": "กำลังโหลดเครื่องยนต์...",
"refresh": "รีเฟรช",
"matrixTitle": "เมทริกซ์ความเข้ากันได้ของเครื่องยนต์",
"matrixTitle": "เอนจิน",
"loadFailed": "ไม่สามารถโหลดเอ็นจิ้น: {{message}}",
"couldNotLoad": "ไม่สามารถโหลดเอ็นจิ้น: {{message}}",
"retry": "ลองอีกครั้ง",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Model kataloğu",
"tab_engines": "Motorlar",
"tab_models": "Modeller",
"moved_title": "Artık model kataloğunda",
"moved_engines_body": "Varsayılan ses, deşifre ve LLM motorunu seçme işlemi Model kataloğu çalışma alanına taşındı.",
"moved_models_body": "İndirilen modellere göz atma, yükleme ve kaldırma işlemleri Model kataloğu çalışma alanına taşındı.",
"open_engines": "Motorları",
"open_models": "Modelleri aç"
"summary_title": "Kurulumunuz",
"change": "Değiştir",
"status_setup": "Kurulum gerekli",
"status_cpu": "CPU yedeği",
"status_off": "Kapalı",
"weights": "İndirilen ağırlıklar",
"storage_link": "Depolama ayarları",
"reco_line": "{{device}} için önerilen {{total}} modelden {{installed}} tanesi yüklü · {{remaining}} GB kaldı",
"install_rest": "Kalanları yükle",
"device_remote": "Uzak"
},
"settings": {
"remote_backend_test": "Bağlantıyı test et",
@@ -779,7 +782,7 @@
"configureProviders": "Sağlayıcıları yapılandır",
"loading": "Motorlar yükleniyor…",
"refresh": "Yenile",
"matrixTitle": "Motor Uyumluluk Matrisi",
"matrixTitle": "Motorlar",
"loadFailed": "Motorlar yüklenemedi: {{message}}",
"couldNotLoad": "Motorlar yüklenemedi: {{message}}",
"retry": "Yeniden dene",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Каталог моделей",
"tab_engines": "Рушії",
"tab_models": "Моделі",
"moved_title": "Тепер у каталозі моделей",
"moved_engines_body": "Вибір типового рушія мовлення, розшифрування та LLM перенесено до розділу «Каталог моделей».",
"moved_models_body": "Перегляд, встановлення та видалення завантажених моделей перенесено до розділу «Каталог моделей».",
"open_engines": "Відкрити рушії",
"open_models": "Відкрити моделі"
"summary_title": "Ваша конфігурація",
"change": "Змінити",
"status_setup": "Потрібне налаштування",
"status_cpu": "Резерв на CPU",
"status_off": "Вимкнено",
"weights": "Завантажені ваги",
"storage_link": "Налаштування сховища",
"reco_line": "Встановлено {{installed}} з {{total}} рекомендованих моделей для {{device}} · залишилось {{remaining}} ГБ",
"install_rest": "Встановити решту",
"device_remote": "Віддалений"
},
"settings": {
"remote_backend_test": "Перевірити з’єднання",
@@ -779,7 +782,7 @@
"configureProviders": "Налаштувати постачальників",
"loading": "Завантаження двигунів…",
"refresh": "Оновити",
"matrixTitle": "Матриця сумісності двигуна",
"matrixTitle": "Движки",
"loadFailed": "Не вдалося завантажити двигуни: {{message}}",
"couldNotLoad": "Не вдалося завантажити двигуни: {{message}}",
"retry": "Повторіть спробу",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "Danh mục mô hình",
"tab_engines": "Engine",
"tab_models": "Mô hình",
"moved_title": "Đã chuyển sang Danh mục mô hình",
"moved_engines_body": "Việc chọn engine mặc định cho giọng nói, chuyển lời nói thành văn bản và LLM đã chuyển sang không gian làm việc Danh mục mô hình.",
"moved_models_body": "Việc duyệt, cài đặt và xoá mô hình đã tải đã chuyển sang không gian làm việc Danh mục mô hình.",
"open_engines": "Mở engine",
"open_models": "Mở mô hình"
"summary_title": "Thiết lập của bạn",
"change": "Thay đổi",
"status_setup": "Cần thiết lập",
"status_cpu": "Dự phòng CPU",
"status_off": "Tắt",
"weights": "Trọng số đã tải",
"storage_link": "Cài đặt lưu trữ",
"reco_line": "Đã cài {{installed}}/{{total}} mô hình đề xuất cho {{device}} · còn {{remaining}} GB",
"install_rest": "Cài phần còn lại",
"device_remote": "Từ xa"
},
"settings": {
"remote_backend_test": "Kiểm tra kết nối",
@@ -779,7 +782,7 @@
"configureProviders": "Cấu hình nhà cung cấp",
"loading": "Đang tải động cơ…",
"refresh": "Làm mới",
"matrixTitle": "Ma trận tương thích động cơ",
"matrixTitle": "ng c",
"loadFailed": "Không tải được động cơ: {{message}}",
"couldNotLoad": "Không thể tải động cơ: {{message}}",
"retry": "Thử lại",
+11 -8
View File
@@ -152,13 +152,16 @@
},
"catalogue": {
"title": "模型库",
"tab_engines": "引擎",
"tab_models": "模型",
"moved_title": "已移至模型库",
"moved_engines_body": "选择默认的语音合成、语音转写和大模型引擎已移至“模型库”工作区。",
"moved_models_body": "浏览、安装和删除已下载模型已移至“模型库”工作区。",
"open_engines": "打开引擎",
"open_models": "打开模型"
"summary_title": "当前配置",
"change": "更改",
"status_setup": "需要设置",
"status_cpu": "CPU 回退",
"status_off": "关闭",
"weights": "已下载的模型权重",
"storage_link": "存储设置",
"reco_line": "{{device}} 的推荐模型已安装 {{installed}}/{{total}} · 还需 {{remaining}} GB",
"install_rest": "安装其余模型",
"device_remote": "远程"
},
"launchpad": {
"greeting": "你好",
@@ -975,7 +978,7 @@
"configureProviders": "配置提供商",
"loading": "加载引擎中…",
"refresh": "刷新",
"matrixTitle": "引擎兼容性矩阵",
"matrixTitle": "引擎",
"loadFailed": "无法加载引擎:{{message}}",
"couldNotLoad": "无法加载引擎:{{message}}",
"retry": "重试",
+11 -8
View File
@@ -33,13 +33,16 @@
},
"catalogue": {
"title": "模型庫",
"tab_engines": "引擎",
"tab_models": "模型",
"moved_title": "已移至模型庫",
"moved_engines_body": "選擇預設的語音合成、語音轉寫與大型語言模型引擎已移至「模型庫」工作區。",
"moved_models_body": "瀏覽、安裝與移除已下載模型已移至「模型庫」工作區。",
"open_engines": "開啟引擎",
"open_models": "開啟模型"
"summary_title": "目前設定",
"change": "變更",
"status_setup": "需要設定",
"status_cpu": "CPU 回退",
"status_off": "關閉",
"weights": "已下載的模型權重",
"storage_link": "儲存設定",
"reco_line": "{{device}} 的推薦模型已安裝 {{installed}}/{{total}} · 尚需 {{remaining}} GB",
"install_rest": "安裝其餘模型",
"device_remote": "遠端"
},
"settings": {
"remote_backend_test": "測試連線",
@@ -779,7 +782,7 @@
"configureProviders": "設定提供者",
"loading": "正在載入引擎...",
"refresh": "重新整理",
"matrixTitle": "引擎相容性矩陣",
"matrixTitle": "配音引擎",
"loadFailed": "無法載入引擎:{{message}}",
"couldNotLoad": "無法載入引擎:{{message}}",
"retry": "重試",
+115 -124
View File
@@ -1,48 +1,43 @@
/**
* ModelCatalogue the workspace where engines and model weights are browsed
* and the app's defaults are chosen.
* ModelCatalogue one page, one axis.
*
* Engine picking and the model store used to be two Settings categories, which
* buried the single most consequential decision in the app (which TTS/ASR/LLM
* engine runs, and which weights are on disk) three clicks deep, split across
* two panes that constantly cross-reference each other. This promotes both to a
* first-class workspace with one pane switch between them; Settings keeps only
* the genuinely settings-shaped remainder (models directory, HF mirror) and
* points here for the rest.
* Reads top-down the way a user thinks about it:
* 1. SetupSummary what speech, transcription, dictation and the language
* model use right now, one line each, with a Change button.
* 2. The engine list for ONE family (TTS / ASR / LLM), the same tested
* EngineCompatibilityMatrix the Engines pane hosted; Change on a summary
* row switches this family and scrolls here.
* 3. That family's downloadable weights (the former Models pane, filtered):
* TTS weights under TTS; offline ASR, streaming dictation and diarisation
* under ASR. LLM engines bring their own weights, so nothing renders there.
* 4. One storage line pointing at Settings Storage.
*
* Deliberately a COMPOSITION, not a rewrite: the panes mount the existing
* `EnginesTab` (engine matrix + the OpenAI-compatible ASR config) and
* `ModelStoreTab` unchanged, so their data contracts, tests and behaviour carry
* over untouched the shared engine cache, the same install/delete flows, and
* the same env-var-wins semantics.
* The previous Engines | Models pane switch put the same decision on two axes
* (family tabs inside one pane, role sections inside the other), duplicated
* dictation in both, and parked storage stats, the HF token and the voice
* preview toggle on the model list. Those now live where they belong
* (Settings Storage / Credentials), and there is no pane to pick.
*
* `pendingCatalogueTab` is the one-shot deep-link hand-off (mirrors Settings'
* `pendingSettingsTab`): a caller sets the pane and navigates here, this page
* consumes it once and clears it so a later plain visit reopens the last pane.
* `pendingCatalogueFamily` is the one-shot deep-link hand-off (mirrors
* Settings' `pendingSettingsTab`): a caller names a family and navigates
* here; this page consumes it once and clears it so a later plain visit
* reopens the last family.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Boxes, CheckCircle, Cpu, HardDriveDownload, RefreshCw } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Boxes } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../store';
import { useModelStatus, useSystemInfo } from '../api/hooks';
import { Badge, Tabs } from '../ui';
import { useModels } from '../api/hooks';
import SetupSummary from '../components/catalogue/SetupSummary';
import EnginesTab from '../components/settings/EnginesTab';
import ModelStoreTab from '../components/settings/ModelStoreTab';
import { fmtBytes } from '../components/settings/models/format';
/** Persisted across visits so the workspace reopens where you left it. */
const PANE_KEY = 'omnivoice.catalogue.pane';
const FAMILY_KEY = 'omnivoice.catalogue.engine-family';
const PANES = ['engines', 'models'];
const FAMILIES = ['tts', 'asr', 'llm'];
function readStoredPane() {
try {
const stored = localStorage.getItem(PANE_KEY);
return PANES.includes(stored) ? stored : 'engines';
} catch {
return 'engines';
}
}
/** Families whose engines load catalogue weights (LLM engines bring their own). */
const WEIGHT_FAMILIES = ['tts', 'asr'];
function readStoredFamily() {
try {
@@ -55,27 +50,15 @@ function readStoredFamily() {
export default function ModelCatalogue() {
const { t } = useTranslation();
const pendingCatalogueTab = useAppStore((s) => s.pendingCatalogueTab);
const pendingCatalogueFamily = useAppStore((s) => s.pendingCatalogueFamily);
const setPendingCatalogueTab = useAppStore((s) => s.setPendingCatalogueTab);
const setPendingCatalogueFamily = useAppStore((s) => s.setPendingCatalogueFamily);
// Seed from the deep-link so the first paint is already the requested pane
// seeding from storage and correcting in an effect would flash the wrong one.
const [pane, setPaneRaw] = useState(() =>
PANES.includes(pendingCatalogueTab) ? pendingCatalogueTab : readStoredPane(),
);
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
// Seed from the deep-link so the first paint is already the requested
// family seeding from storage and correcting in an effect would flash.
const [family, setFamilyRaw] = useState(() =>
FAMILIES.includes(pendingCatalogueFamily) ? pendingCatalogueFamily : readStoredFamily(),
);
const setPane = useCallback((next) => {
setPaneRaw(next);
try {
localStorage.setItem(PANE_KEY, next);
} catch {
/* private mode / quota — the pane still switches, it just won't persist */
}
}, []);
const setFamily = useCallback((next) => {
setFamilyRaw(next);
try {
@@ -85,53 +68,26 @@ export default function ModelCatalogue() {
}
}, []);
// Consume the one-shot deep-link (including a repeat request for the pane
// we're already on, which must still clear).
// Consume the one-shot deep-link (including a repeat request for the
// family we're already on, which must still clear).
useEffect(() => {
if (!pendingCatalogueTab) return;
if (PANES.includes(pendingCatalogueTab)) setPane(pendingCatalogueTab);
if (!pendingCatalogueFamily) return;
if (FAMILIES.includes(pendingCatalogueFamily)) setFamily(pendingCatalogueFamily);
setPendingCatalogueTab(null);
setPendingCatalogueFamily(null);
}, [
pendingCatalogueTab,
pendingCatalogueFamily,
setPendingCatalogueFamily,
setPendingCatalogueTab,
setFamily,
setPane,
]);
}, [pendingCatalogueFamily, setPendingCatalogueFamily, setFamily]);
const { data: info } = useSystemInfo();
const { data: status } = useModelStatus();
// The loaded-model pill ModelStoreTab renders in its header. Lives here now
// that this page not Settings hosts the model store.
const modelBadge =
status?.status === 'ready' ? (
<Badge tone="success">
<CheckCircle size={11} /> {t('models.ready_badge')}
</Badge>
) : status?.status === 'loading' ? (
<Badge tone="warn">
<RefreshCw size={11} className="spinner" /> {t('models.loading_badge')}
</Badge>
) : (
<Badge tone="warn">{t('models.idle_badge')}</Badge>
);
// Tabs, not a two-state Segmented: these are two workspaces of a catalogue,
// not one setting with an on/off reading, and the room to add a third pane
// later is free. Tabs also carry roving tabindex + role="tab" from the
// primitive, which the switch had to describe with an aria-label.
const paneItems = useMemo(
() => [
{ id: 'engines', label: t('catalogue.tab_engines'), icon: Cpu },
{ id: 'models', label: t('catalogue.tab_models'), icon: HardDriveDownload },
],
[t],
const enginesRef = useRef(null);
const changeFamily = useCallback(
(next) => {
setFamily(next);
enginesRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
},
[setFamily],
);
const { data: models } = useModels();
const cacheDir = models?.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~');
return (
// Same container-query shell as Settings: the app is zoom-scaled, so
// viewport media queries fire at the wrong logical width under Tauri.
@@ -140,48 +96,83 @@ export default function ModelCatalogue() {
data-testid="model-catalogue"
>
<div className="mx-auto box-border w-full max-w-[1500px] px-[34px] pb-[56px] pt-[34px] font-sans @max-[900px]/catalogue-shell:px-[24px] @max-[900px]/catalogue-shell:pb-[36px] @max-[900px]/catalogue-shell:pt-[26px] @max-[560px]/catalogue-shell:px-[14px]">
{/* Treat this as a workspace, not a Settings card: one editorial title,
one quiet navigation line, then the data surface. */}
<header className="mb-[24px] flex flex-wrap items-end justify-between gap-x-[32px] gap-y-[18px] border-b border-[color-mix(in_srgb,var(--chrome-fg)_9%,transparent)] pb-[18px] @max-[560px]/catalogue-shell:flex-col @max-[560px]/catalogue-shell:items-start">
<div className="flex items-center gap-[12px]">
<span
className="inline-flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[12px] bg-[color-mix(in_srgb,var(--chrome-accent)_11%,transparent)] text-[color:var(--chrome-accent)]"
aria-hidden="true"
>
<Boxes size={17} strokeWidth={1.6} />
</span>
<h1 className="m-0 min-w-0 [font-family:var(--font-serif)] text-[2rem] font-normal leading-none tracking-[-0.025em] text-[color:var(--chrome-fg)] @max-[560px]/catalogue-shell:text-[1.55rem]">
{t('catalogue.title')}
</h1>
</div>
<Tabs
items={paneItems}
value={pane}
onChange={setPane}
variant="underline"
idPrefix="catalogue-pane"
aria-label={t('catalogue.title')}
data-testid="catalogue-pane-switch"
/>
<header className="mb-[22px] flex items-center gap-[12px]">
<span
className="inline-flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[12px] bg-[color-mix(in_srgb,var(--chrome-accent)_11%,transparent)] text-[color:var(--chrome-accent)]"
aria-hidden="true"
>
<Boxes size={17} strokeWidth={1.6} />
</span>
<h1 className="m-0 min-w-0 [font-family:var(--font-serif)] text-[2rem] font-normal leading-none tracking-[-0.025em] text-[color:var(--chrome-fg)] @max-[560px]/catalogue-shell:text-[1.55rem]">
{t('catalogue.title')}
</h1>
</header>
{/* Full-bleed: engine rows and the model table are wide, data-dense
<SetupSummary onChange={changeFamily} />
{/* Full-bleed: engine rows and the weights table are wide, data-dense
surfaces a reading-width column would only add horizontal
scrolling inside them. */}
<div
key={pane}
id={`catalogue-pane-panel-${pane}`}
data-testid={`catalogue-pane-${pane}`}
role="tabpanel"
aria-labelledby={`catalogue-pane-tab-${pane}`}
className="min-w-0 [&>*:first-child]:mt-0"
<section
ref={enginesRef}
data-testid="catalogue-engines"
className="min-w-0 scroll-mt-[24px] [&>*:first-child]:mt-0"
>
{pane === 'engines' ? (
<EnginesTab initialFamily={family} onFamilyChange={setFamily} catalogueLayout />
) : (
<ModelStoreTab info={info} modelBadge={modelBadge} catalogueLayout />
<EnginesTab initialFamily={family} onFamilyChange={setFamily} catalogueLayout />
</section>
{WEIGHT_FAMILIES.includes(family) && (
<section
data-testid="catalogue-weights"
aria-labelledby="catalogue-weights-title"
className="mt-[40px] min-w-0"
>
<h2
id="catalogue-weights-title"
className="m-0 mb-[14px] px-[2px] text-[length:var(--text-lg)] font-semibold text-[color:var(--chrome-fg)]"
>
{t('catalogue.weights')}
</h2>
<ModelStoreTab key={family} family={family} />
</section>
)}
<footer
data-testid="catalogue-storage"
className="mt-[40px] flex flex-wrap items-center gap-x-[10px] gap-y-[4px] border-t border-border pt-[14px] font-mono text-[11px] text-muted-foreground"
>
{models && (
<>
<span className="text-foreground">
{fmtBytes(models.total_installed_bytes) || '0 B'}
</span>
{models.disk_free_gb != null && (
<>
<span aria-hidden="true">·</span>
<span title={t('models.disk_free_title')}>
{t('models.disk_free', { size: `${models.disk_free_gb} GB` })}
</span>
</>
)}
{cacheDir && (
<>
<span aria-hidden="true">·</span>
<code className="truncate" title={models.hf_cache_dir}>
{cacheDir}
</code>
</>
)}
</>
)}
</div>
<button
type="button"
className="ml-auto cursor-pointer border-0 bg-transparent p-0 font-mono text-[11px] text-accent hover:underline"
onClick={() => openSettingsTab('storage')}
data-testid="catalogue-storage-link"
>
{t('catalogue.storage_link')}
</button>
</footer>
</div>
</div>
);
+94 -103
View File
@@ -1,160 +1,151 @@
// Model Catalogue workspace pane switching, deep-link hand-off, persistence.
// Model Catalogue workspace one family axis, deep-link hand-off, persistence.
//
// The two panes are mounted stand-ins for the real EnginesTab / ModelStoreTab
// (both of which have their own suites and both of which hit the network); this
// suite is about the workspace shell around them.
// The summary, engine list and weights list are mounted stand-ins for the real
// components (each has its own suite and hits the network); this suite is
// about the page around them: one family drives both lists, LLM has no
// weights, Change on the summary switches the family, and the old two-pane
// switch is gone.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
vi.mock('../components/settings/EnginesTab', () => ({
default: ({ initialFamily, catalogueLayout }) => (
default: ({ initialFamily, onFamilyChange, catalogueLayout }) => (
<div data-testid="stub-engines" data-catalogue-layout={catalogueLayout || undefined}>
{initialFamily}
<button type="button" onClick={() => onFamilyChange('llm')}>
to-llm
</button>
</div>
),
}));
vi.mock('../components/settings/ModelStoreTab', () => ({
default: ({ modelBadge, catalogueLayout }) => (
<div data-testid="stub-models" data-catalogue-layout={catalogueLayout || undefined}>
{modelBadge}
default: ({ family }) => <div data-testid="stub-weights">{family}</div>,
}));
vi.mock('../components/catalogue/SetupSummary', () => ({
default: ({ onChange }) => (
<div data-testid="stub-summary">
<button type="button" onClick={() => onChange('asr')}>
change-asr
</button>
</div>
),
}));
vi.mock('../api/hooks', () => ({
useSystemInfo: () => ({ data: { has_hf_token: false } }),
useModelStatus: () => ({ data: { status: 'ready' } }),
useModels: () => ({
data: {
total_installed_bytes: 3 * 1024 ** 3,
disk_free_gb: 41,
hf_cache_dir: '/Users/dev/.cache/huggingface',
},
}),
}));
import { useAppStore } from '../store';
import ModelCatalogue from './ModelCatalogue';
// Radix Tabs activate on POINTER DOWN, not click a bare fireEvent.click
// leaves the pane unchanged and reads as a broken switcher. Drive the tab the
// way a pointer does.
const clickTab = (name) => {
const tab = screen.getByRole('tab', { name });
fireEvent.pointerDown(tab, { button: 0, ctrlKey: false, pointerType: 'mouse' });
fireEvent.mouseDown(tab, { button: 0 });
fireEvent.click(tab);
};
describe('ModelCatalogue', () => {
beforeEach(() => {
localStorage.clear();
act(() => {
useAppStore.setState({
mode: 'catalogue',
pendingCatalogueTab: null,
pendingCatalogueFamily: null,
});
useAppStore.setState({ mode: 'catalogue', pendingCatalogueFamily: null });
});
});
it('opens on the Engines pane and switches to Models', () => {
it('is one page: summary, then the TTS engines and TTS weights, no pane switch', () => {
render(<ModelCatalogue />);
expect(screen.getByTestId('stub-engines')).toBeInTheDocument();
expect(screen.getByRole('tabpanel', { name: 'Engines' })).toBeInTheDocument();
expect(screen.getByTestId('stub-summary')).toBeInTheDocument();
expect(screen.getByTestId('stub-engines')).toHaveTextContent('tts');
expect(screen.getByTestId('stub-engines')).toHaveAttribute('data-catalogue-layout', 'true');
expect(screen.queryByTestId('stub-models')).toBeNull();
clickTab('Models');
expect(screen.getByTestId('stub-models')).toBeInTheDocument();
expect(screen.getByRole('tabpanel', { name: 'Models' })).toBeInTheDocument();
expect(screen.getByTestId('stub-models')).toHaveAttribute('data-catalogue-layout', 'true');
expect(screen.queryByTestId('stub-engines')).toBeNull();
expect(screen.getByTestId('stub-weights')).toHaveTextContent('tts');
expect(screen.queryByRole('tab')).toBeNull();
// Reading order: summary above engines above weights.
const [summary, engines, weights] = ['stub-summary', 'stub-engines', 'stub-weights'].map((id) =>
screen.getByTestId(id),
);
expect(
summary.compareDocumentPosition(engines) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
engines.compareDocumentPosition(weights) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('uses the wide workspace shell for data-heavy catalogue panes', () => {
it('Change on the summary switches the family for engines and weights together', () => {
render(<ModelCatalogue />);
fireEvent.click(screen.getByText('change-asr'));
expect(screen.getByTestId('stub-engines')).toHaveTextContent('asr');
expect(screen.getByTestId('stub-weights')).toHaveTextContent('asr');
});
it('LLM engines bring their own weights, so the weights block disappears', () => {
render(<ModelCatalogue />);
fireEvent.click(screen.getByText('to-llm'));
expect(screen.getByTestId('stub-engines')).toHaveTextContent('llm');
expect(screen.queryByTestId('stub-weights')).toBeNull();
});
it('uses the wide workspace shell for data-heavy lists', () => {
render(<ModelCatalogue />);
expect(screen.getByTestId('model-catalogue').firstElementChild).toHaveClass('max-w-[1500px]');
});
it('associates each tab with its labelled panel', () => {
it('remembers the family across visits', () => {
const { unmount } = render(<ModelCatalogue />);
fireEvent.click(screen.getByText('change-asr'));
unmount();
render(<ModelCatalogue />);
const enginesTab = screen.getByRole('tab', { name: 'Engines' });
const enginesPanel = screen.getByRole('tabpanel', { name: 'Engines' });
expect(enginesTab).toHaveAttribute('aria-controls', enginesPanel.id);
expect(enginesPanel).toHaveAttribute('aria-labelledby', enginesTab.id);
const modelsTab = screen.getByRole('tab', { name: 'Models' });
clickTab('Models');
const modelsPanel = screen.getByRole('tabpanel', { name: 'Models' });
expect(modelsTab).toHaveAttribute('aria-controls', modelsPanel.id);
expect(modelsPanel).toHaveAttribute('aria-labelledby', modelsTab.id);
expect(screen.getByTestId('stub-engines')).toHaveTextContent('asr');
});
it('remembers the pane across visits', () => {
const first = render(<ModelCatalogue />);
clickTab('Models');
first.unmount();
it('honours a pending family deep-link on first paint and clears it', () => {
act(() => useAppStore.setState({ pendingCatalogueFamily: 'llm' }));
render(<ModelCatalogue />);
expect(screen.getByTestId('stub-models')).toBeInTheDocument();
expect(screen.getByTestId('stub-engines')).toHaveTextContent('llm');
expect(useAppStore.getState().pendingCatalogueFamily).toBeNull();
});
it('honours a pending deep-link pane on first paint and clears it', () => {
it('switches family when a deep-link arrives while already mounted', () => {
render(<ModelCatalogue />);
expect(screen.getByTestId('stub-engines')).toHaveTextContent('tts');
act(() => {
useAppStore.setState({ pendingCatalogueTab: 'models' });
useAppStore.getState().openCatalogue('asr');
});
render(<ModelCatalogue />);
expect(screen.getByTestId('stub-models')).toBeInTheDocument();
expect(useAppStore.getState().pendingCatalogueTab).toBeNull();
});
it('switches pane when a deep-link arrives while already mounted', () => {
render(<ModelCatalogue />);
expect(screen.getByTestId('stub-engines')).toBeInTheDocument();
act(() => {
useAppStore.getState().openCatalogue('models');
});
expect(screen.getByTestId('stub-models')).toBeInTheDocument();
expect(useAppStore.getState().pendingCatalogueTab).toBeNull();
});
it('honours an engine-family deep link and clears it after hand-off', () => {
act(() => {
useAppStore.getState().openCatalogue({ pane: 'engines', family: 'asr' });
});
render(<ModelCatalogue />);
expect(screen.getByTestId('stub-engines')).toHaveTextContent('asr');
expect(useAppStore.getState().pendingCatalogueFamily).toBeNull();
});
it('passes the loaded-model badge down to the model store pane', () => {
act(() => {
useAppStore.setState({ pendingCatalogueTab: 'models' });
});
it('shows one storage line and links it to Settings → Storage', () => {
render(<ModelCatalogue />);
// 'ready' status the ready badge renders inside the models pane.
expect(screen.getByTestId('stub-models').textContent).toBeTruthy();
const footer = screen.getByTestId('catalogue-storage');
expect(footer).toHaveTextContent('41 GB');
expect(footer).toHaveTextContent('~/.cache/huggingface');
fireEvent.click(screen.getByTestId('catalogue-storage-link'));
expect(useAppStore.getState().mode).toBe('settings');
expect(useAppStore.getState().pendingSettingsTab).toBe('storage');
});
});
describe('openCatalogue', () => {
it('navigates to the catalogue workspace on the requested pane', () => {
act(() => {
useAppStore.setState({ mode: 'settings', pendingCatalogueTab: null });
useAppStore.getState().openCatalogue('models');
});
beforeEach(() => {
act(() => useAppStore.setState({ mode: 'studio', pendingCatalogueFamily: null }));
});
it('navigates to the catalogue on the requested family', () => {
act(() => useAppStore.getState().openCatalogue('llm'));
expect(useAppStore.getState().mode).toBe('catalogue');
expect(useAppStore.getState().pendingCatalogueTab).toBe('models');
});
it('defaults to the engines pane', () => {
act(() => {
useAppStore.setState({ pendingCatalogueTab: null });
useAppStore.getState().openCatalogue();
});
expect(useAppStore.getState().pendingCatalogueTab).toBe('engines');
});
it('accepts an object deep link with a family', () => {
act(() => {
useAppStore.getState().openCatalogue({ pane: 'engines', family: 'llm' });
});
expect(useAppStore.getState().pendingCatalogueTab).toBe('engines');
expect(useAppStore.getState().pendingCatalogueFamily).toBe('llm');
});
it('accepts the object form and ignores the retired pane key', () => {
act(() => useAppStore.getState().openCatalogue({ pane: 'models', family: 'asr' }));
expect(useAppStore.getState().mode).toBe('catalogue');
expect(useAppStore.getState().pendingCatalogueFamily).toBe('asr');
});
it('defaults to the last family when called bare', () => {
act(() => useAppStore.getState().openCatalogue());
expect(useAppStore.getState().mode).toBe('catalogue');
expect(useAppStore.getState().pendingCatalogueFamily).toBeNull();
});
});
+14 -16
View File
@@ -35,7 +35,7 @@ import UpdatesPanel from '../components/UpdatesPanel';
import GeneralTab from '../components/settings/GeneralTab';
// Engine selection + the model store moved to the Model Catalogue workspace
// (pages/ModelCatalogue.jsx); these categories now signpost it.
import CataloguePointer from '../components/settings/CataloguePointer';
import VoicePreviewsPanel from '../components/settings/VoicePreviewsPanel';
import HotkeyTab from '../components/settings/HotkeyTab';
import TranslationTab from '../components/settings/TranslationTab';
import NetworkTab from '../components/settings/NetworkTab';
@@ -399,20 +399,6 @@ export default function Settings() {
<GeneralTab />
</>
);
case 'engines':
return <CataloguePointer area="engines" />;
case 'models':
// What stays here is the storage-shaped remainder: where weights live
// on disk and which mirror they come from (both restart-bound, hence
// the category's `restart: true`). Browsing / installing / removing
// them is the Model Catalogue's Models pane.
return (
<>
<StoragePanel />
<HFMirrorPanel />
<CataloguePointer area="models" />
</>
);
case 'dictation':
return (
<>
@@ -436,16 +422,28 @@ export default function Settings() {
</>
);
case 'storage':
// Everything disk-shaped in one place: usage, the models directory
// (restart-bound), data/output paths, and the voice-preview cache
// toggle. Browsing / installing / removing weights is the Model
// Catalogue.
return (
<>
<StorageUsagePanel />
<StoragePanel />
<StorageTab />
<VoicePreviewsPanel />
</>
);
case 'permissions':
return <PermissionsPanel />;
case 'network':
return <NetworkTab />;
// The HF mirror is a network choice (which host downloads come from).
return (
<>
<NetworkTab />
<HFMirrorPanel />
</>
);
case 'audio-tools':
return <AudioToolsPanel />;
case 'sharing':
+13 -16
View File
@@ -33,8 +33,12 @@ export type AppMode =
| 'settings';
/** Which pane the Model Catalogue workspace opens on. */
export type CatalogueTab = 'engines' | 'models';
export type CatalogueTarget = CatalogueTab | { pane?: CatalogueTab; family?: EngineFamily };
/**
* Deep-link target for the Model Catalogue: the engine family to open on. The
* page has one axis now (TTS / ASR / LLM) the old `pane` key is tolerated
* and ignored so stale callers keep navigating.
*/
export type CatalogueTarget = EngineFamily | { family?: EngineFamily | null; pane?: string };
/**
* The Voice workspace's "Define voice" method (was the Clone/Design tab
@@ -77,12 +81,9 @@ export interface UiSlice {
*/
pendingSettingsTab: string | null;
/**
* One-shot hand-off for "open the Model Catalogue on a specific pane" the
* catalogue twin of `pendingSettingsTab`, used by the Settings pointers that
* replaced the old Engines / Model Store panels.
* One-shot hand-off for "open the Model Catalogue on a specific engine
* family" the catalogue twin of `pendingSettingsTab`.
*/
pendingCatalogueTab: CatalogueTab | null;
/** Optional engine family to focus after entering the catalogue. */
pendingCatalogueFamily: EngineFamily | null;
isSidebarCollapsed: boolean;
isSidebarProjectsCollapsed: boolean;
@@ -102,12 +103,11 @@ export interface UiSlice {
setModeBeforeVoice: (mode: AppMode | null) => void;
setPendingProfileId: (id: string | null) => void;
setPendingSettingsTab: (tab: string | null) => void;
setPendingCatalogueTab: (tab: CatalogueTab | null) => void;
setPendingCatalogueFamily: (family: EngineFamily | null) => void;
/** Navigate to Settings on a specific tab in one call. */
openSettingsTab: (tab: string) => void;
/** Navigate to the Model Catalogue on a specific pane in one call. */
openCatalogue: (target?: CatalogueTarget) => void;
/** Navigate to the Model Catalogue on a specific engine family in one call. */
openCatalogue: (target?: CatalogueTarget | null) => void;
setIsSidebarCollapsed: (collapsed: boolean) => void;
setIsSidebarProjectsCollapsed: (collapsed: boolean) => void;
setSidebarTab: (tab: SidebarTab) => void;
@@ -132,7 +132,6 @@ export const createUiSlice: StateCreator<UiSlice, [], [], UiSlice> = (set, get)
modeBeforeVoice: null,
pendingProfileId: null,
pendingSettingsTab: null,
pendingCatalogueTab: null,
pendingCatalogueFamily: null,
isSidebarCollapsed: false,
isSidebarProjectsCollapsed: false,
@@ -154,13 +153,11 @@ export const createUiSlice: StateCreator<UiSlice, [], [], UiSlice> = (set, get)
setModeBeforeVoice: (mode) => set({ modeBeforeVoice: mode }),
setPendingProfileId: (id) => set({ pendingProfileId: id }),
setPendingSettingsTab: (tab) => set({ pendingSettingsTab: tab }),
setPendingCatalogueTab: (tab) => set({ pendingCatalogueTab: tab }),
setPendingCatalogueFamily: (family) => set({ pendingCatalogueFamily: family }),
openSettingsTab: (tab) => set({ pendingSettingsTab: tab, mode: 'settings' }),
openCatalogue: (target = 'engines') => {
const { pane = 'engines', family = null } =
typeof target === 'string' ? { pane: target } : target;
set({ pendingCatalogueTab: pane, pendingCatalogueFamily: family, mode: 'catalogue' });
openCatalogue: (target = null) => {
const family = typeof target === 'string' ? target : (target?.family ?? null);
set({ pendingCatalogueFamily: family, mode: 'catalogue' });
},
setIsSidebarCollapsed: (collapsed) => set({ isSidebarCollapsed: collapsed }),
setIsSidebarProjectsCollapsed: (collapsed) => set({ isSidebarProjectsCollapsed: collapsed }),
@@ -1153,7 +1153,7 @@ describe('EngineCompatibilityMatrix', () => {
/>,
);
await waitFor(() => screen.getByText('OmniVoice (test)'));
expect(screen.getByText('Engine Compatibility Matrix')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Engines' })).toBeInTheDocument();
expect(document.querySelectorAll('.engine-matrix__tab-family').length).toBe(3);
const labels = document.querySelectorAll('.engine-matrix__tab-label');
expect(labels).toHaveLength(3);
+51 -3
View File
@@ -228,8 +228,9 @@ vi.mock('../components/settings/models/ModelsTable', () => ({
}));
import ModelStoreTab from '../components/settings/ModelStoreTab';
import { scopeReco } from '../components/settings/models/sections';
function mountTab() {
function mountTab(props = {}) {
global.EventSource = class {
constructor() {
this.onmessage = null;
@@ -238,7 +239,7 @@ function mountTab() {
};
return render(
<I18nextProvider i18n={i18n}>
<ModelStoreTab info={{ has_hf_token: true }} modelBadge={null} />
<ModelStoreTab info={{ has_hf_token: true }} {...props} />
</I18nextProvider>,
);
}
@@ -251,7 +252,7 @@ describe('Model Store — grouped catalog', () => {
it('renders role sections in order with localized titles', async () => {
mountTab();
await waitFor(() => expect(screen.getByTestId('models-section-tts')).toBeInTheDocument());
expect(screen.getByTestId('model-list-panel')).toHaveClass('min-h-[95%]');
expect(screen.getByTestId('model-list-panel')).toBeInTheDocument();
expect(screen.getByTestId('model-list-area')).toHaveClass('flex-1');
const keys = ['tts', 'asr', 'dictation', 'diarisation'];
const sections = keys.map((k) => screen.getByTestId(`models-section-${k}`));
@@ -313,3 +314,50 @@ describe('Model Store — grouped catalog', () => {
expect(search).toHaveValue('');
});
});
// Family scoping: the catalogue lists one family's weights under its engines
describe('Model Store — scoped to one engine family', () => {
it('ASR carries offline ASR, streaming dictation and diarisation; TTS only TTS', async () => {
mountTab({ family: 'asr' });
await waitFor(() => expect(screen.getByTestId('models-section-asr')).toBeInTheDocument());
expect(screen.getByTestId('models-section-dictation')).toBeInTheDocument();
expect(screen.getByTestId('models-section-diarisation')).toBeInTheDocument();
expect(screen.queryByTestId('models-section-tts')).not.toBeInTheDocument();
expect(visibleLabels()).not.toContain('VoiceStudio TTS');
});
it('TTS shows only TTS weights and searching within it stays scoped', async () => {
mountTab({ family: 'tts' });
await waitFor(() => expect(screen.getByTestId('models-section-tts')).toBeInTheDocument());
expect(screen.queryByTestId('models-section-asr')).not.toBeInTheDocument();
const search = screen.getByRole('searchbox', { name: t('models.search_label') });
fireEvent.change(search, { target: { value: 'parakeet' } });
// Parakeet is an ASR row no match inside TTS, so the scoped empty state shows.
await waitFor(() => expect(screen.getByText(t('models.no_matches'))).toBeInTheDocument());
});
it('scopeReco narrows the device preset to the family and recomputes totals', () => {
const reco = {
device: { label: 'M2' },
all_installed: false,
download_gb_remaining: 5.3,
total_gb: 7.7,
models: [
{ repo_id: 'a/tts', role: 'TTS', installed: true, size_gb: 2.4 },
{ repo_id: 'b/asr', role: 'ASR', installed: false, size_gb: 2.9 },
{ repo_id: 'c/dict', role: 'ASR', engine: 'sherpa-onnx', installed: false, size_gb: 2.4 },
],
};
expect(scopeReco(reco, null)).toBe(reco);
const tts = scopeReco(reco, 'tts');
expect(tts.models.map((m) => m.repo_id)).toEqual(['a/tts']);
expect(tts.all_installed).toBe(true);
expect(tts.download_gb_remaining).toBe(0);
const asr = scopeReco(reco, 'asr');
expect(asr.models.map((m) => m.repo_id)).toEqual(['b/asr', 'c/dict']);
expect(asr.all_installed).toBe(false);
expect(asr.download_gb_remaining).toBe(5.3);
expect(scopeReco(reco, 'llm')).toBeNull();
});
});
@@ -1,88 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n';
// Enhancement: unify the two HF-token entry points
// The Models toolbar used to save the token via /system/set-env (env var +
// HF-CLI file) while Settings Credentials saves to the encrypted app store
// two stores with an asymmetric clear path (Credentials' "Clear" couldn't
// remove a toolbar-set token). This test locks the toolbar onto the SAME
// canonical app-store endpoint Credentials uses (/api/settings/hf-token), and
// guards against a regression back to /system/set-env.
const apiPost = vi.fn(() => Promise.resolve({ active: 'app', sources: [] }));
vi.mock('../api/client', () => ({
apiPost: (...a) => apiPost(...a),
apiFetch: vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
}));
const refetch = vi.fn();
vi.mock('../api/hooks', () => ({
useModels: () => ({
data: {
models: [],
total_installed_bytes: 0,
disk_free_gb: 42.5,
hf_cache_dir: '/home/u/.cache/huggingface',
},
isLoading: false,
refetch,
}),
useRecommendations: () => ({ data: null, refetch }),
useInstallModel: () => ({ mutateAsync: vi.fn() }),
useDeleteModel: () => ({ mutateAsync: vi.fn() }),
}));
vi.mock('../api/setup', () => ({
setupDownloadStreamUrl: () => 'http://localhost/stream',
cancelInstallModel: vi.fn(),
}));
vi.mock('../api/external', () => ({ openExternal: vi.fn() }));
// Keep the render light + focused on the toolbar.
vi.mock('../components/settings/models/ModelsTable', () => ({ default: () => null }));
vi.mock('../components/settings/models/RecoBanner', () => ({ default: () => null }));
import ModelStoreTab from '../components/settings/ModelStoreTab';
function withI18n(node) {
return <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
}
describe('Model Store toolbar — HF token saves to the canonical app store', () => {
beforeEach(() => {
apiPost.mockClear();
global.EventSource = class {
constructor() {
this.onmessage = null;
}
close() {}
};
});
afterEach(() => vi.restoreAllMocks());
async function openAndSave(token) {
render(withI18n(<ModelStoreTab info={{ has_hf_token: false }} modelBadge={null} />));
// The compact toolbar shows a "HF Token" trigger when no token is set.
fireEvent.click(screen.getByRole('button', { name: /HF Token/i }));
const input = screen.getByPlaceholderText(/hf_/i);
fireEvent.change(input, { target: { value: token } });
fireEvent.click(screen.getByRole('button', { name: i18n.t('common.save') }));
}
it('POSTs the token to /api/settings/hf-token (same store as Credentials)', async () => {
await openAndSave('hf_abc123');
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
expect(apiPost).toHaveBeenCalledWith('/api/settings/hf-token', { token: 'hf_abc123' });
});
it('never routes the token through the legacy /system/set-env store', async () => {
await openAndSave('hf_xyz');
await waitFor(() => expect(apiPost).toHaveBeenCalled());
for (const call of apiPost.mock.calls) {
expect(call[0]).not.toContain('/system/set-env');
}
expect(apiPost).toHaveBeenCalledWith('/api/settings/hf-token', { token: 'hf_xyz' });
});
});