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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)]"
|
||||
>
|
||||
|
||||
@@ -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 row’s 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')}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": "أعد المحاولة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’espace Catalogue de modèles.",
|
||||
"moved_models_body": "Parcourir, installer et supprimer les modèles téléchargés se trouve maintenant dans l’espace 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",
|
||||
|
||||
@@ -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": "पुनः प्रयास करें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "再試行",
|
||||
|
||||
@@ -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": "재시도",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Повторить попытку",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ลองอีกครั้ง",
|
||||
|
||||
@@ -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ı aç",
|
||||
"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",
|
||||
|
||||
@@ -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": "Повторіть спробу",
|
||||
|
||||
@@ -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": "Công cụ",
|
||||
"loadFailed": "Không tải được động cơ: {{message}}",
|
||||
"couldNotLoad": "Không thể tải động cơ: {{message}}",
|
||||
"retry": "Thử lại",
|
||||
|
||||
@@ -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": "重试",
|
||||
|
||||
@@ -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": "重試",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user