Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a22952f9c | ||
|
|
ff56865cf7 | ||
|
|
9c81e3389d | ||
|
|
3f62471bad | ||
|
|
254f071b45 | ||
|
|
d1522cbba0 | ||
|
|
d3e88c0f3e | ||
|
|
fb0508f4f6 | ||
|
|
855a72038b | ||
|
|
e0a7b2202d | ||
|
|
f7be62207e | ||
|
|
7762bc48bd | ||
|
|
5562aa16a7 | ||
|
|
34c8a33628 | ||
|
|
17e2bb5ed5 | ||
|
|
fbac0de817 | ||
|
|
2e71cc3744 | ||
|
|
e816a2c24e | ||
|
|
fd083749c6 | ||
|
|
b603b9f78d | ||
|
|
2f3e40db24 | ||
|
|
6978f90f16 | ||
|
|
17ae952810 |
@@ -74,9 +74,10 @@ jobs:
|
||||
- name: Validate install docs against desktop-prod.sh
|
||||
run: python scripts/validate-install-docs.py
|
||||
|
||||
# `backend/tests/` stubs core.config in sys.modules to avoid the heavy
|
||||
# main app import chain — that pollutes import state for other modules,
|
||||
# so it runs in its own pytest session to stay isolated from tests/.
|
||||
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
|
||||
# import chain) with a hermetic data dir from its conftest.py. It no
|
||||
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
|
||||
# the separate session is kept for cheaper, clearer CI output.
|
||||
- name: Run pytest (backend/tests, isolated)
|
||||
run: uv run pytest backend/tests/ -q --tb=short
|
||||
|
||||
|
||||
@@ -6,6 +6,46 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [0.3.18] — 2026-07-12
|
||||
|
||||
The self-sufficiency release. Two long-standing "works on my network / works after four terminal commands" walls came down: model downloads now find a reachable Hugging Face endpoint on their own (no more restricted-network first-run dead-ends), and IndexTTS-2 — previously the only engine that demanded a manual clone-venv-install ritual — installs itself with one click. Under the hood, a test-debt sweep hardened the suite that guards all of it.
|
||||
|
||||
### Added
|
||||
|
||||
- **IndexTTS-2 installs itself now — one click in Settings → Engines.** The emotion-controlled cloning engine used to demand four terminal steps (clone the repo, create a venv, `uv pip install`, set an environment variable); the row now has an Install button that does all of it — source fetch (git, with a no-git tarball fallback), an isolated venv that keeps its `transformers<5` away from the app, the ~6 GB model weights (via your configured/auto-selected Hugging Face endpoint), and configuration — with step-by-step progress, a disk-space check before anything is written, and resumable repair if anything is interrupted. The engine is usable the moment the job finishes, no restart; existing manual installs are detected and left untouched, and the manual steps remain as a collapsible fallback. The provisioner is parametrized so future sidecar engines (MOSS-v1.5, dots.tts, Confucius4) can reuse it. (#1083)
|
||||
|
||||
- **Model downloads now find a reachable Hugging Face endpoint on their own.** On networks where huggingface.co is blocked or slow (the class of first-run dead-ends behind #984), the app quietly probes the official endpoint and the hf-mirror.com community mirror, picks whichever actually works, remembers the choice, and re-checks only when a download fails or the pick goes stale — so a restricted-network first run reaches a working voice instead of a wall of connection errors. Anyone who already set a mirror (env var, pref, or Settings) stays exactly where they pointed: explicit choices are never auto-switched, and Settings → Models → Hugging Face mirror now shows the automatic pick with its measured latency plus a "Test again" button. Probes only touch the two download hosts — no geo-IP, no telemetry — and every download stays checksum-verified by `huggingface_hub` regardless of endpoint. (#1082)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Concurrent settings writes can no longer drop each other.** Two parts of the app saving preferences at the same moment (say, an engine install finishing while you change a setting) could silently lose whichever save landed first; preference writes are now serialized, with a regression test. Found and fixed as part of the IndexTTS-2 installer work. (#1083)
|
||||
|
||||
### CI
|
||||
|
||||
- **Test-suite debt sweep.** Exports coverage (26 new tests, which caught two real router bugs), fp16 default-dtype leak instrumentation, and a suite-order pollution class root-caused at its source — the checks that guard every release got stricter. (#1081)
|
||||
|
||||
## [0.3.17] — 2026-07-11
|
||||
|
||||
The polish release. The dubbing workspace can no longer trap you — an interrupted dub session used to relaunch into an eternal spinner that even reinstalling couldn't clear (thank you @nanai97 for the screenshot that cracked it). A 58-finding audit of every Settings panel got fixed end to end, **FFmpeg and yt-dlp stopped being your problem** (the app provisions its own, with a new Audio tools panel when you want control), the Engines and Models pages went compact and tabbed, the launcher stopped trusting half-dead backends, and the app finally opens at 100% scale.
|
||||
|
||||
### Added
|
||||
|
||||
- **FFmpeg, FFprobe, and yt-dlp stopped being your problem.** The setup wizard no longer lists them as system requirements with "brew install" homework — the app provisions them itself: shipped installs already bundle them, and when nothing is found the backend downloads its own checksum-pinned static build in the background, showing a single actionable card only if that fails. A new **Settings → Audio tools** panel gives back the control: per-tool version and origin (App package / Bundled / System / Custom), update / use-system / choose-file / restore-bundled — and **one-click yt-dlp updates** that survive app upgrades, because video-site support changes faster than releases. Install docs updated to match. (#1071)
|
||||
|
||||
- **The Engines and Models pages got compact and tabbed.** Engines is now one section with TTS / ASR / LLM tabs; every engine is a strict two-line, fixed-height row with truncated text and aligned status / GPU / isolation / action columns, so the whole engine list fits one screen — details like "Why unavailable?" expand below the row instead of stretching it. Models rows tightened the same way. (#1072)
|
||||
|
||||
- **The Engines and Models pages got a full readability-and-features pass.** Every engine row now carries a small identity mark and honest capability badges (voice cloning, device routing with the reason on hover, sidecar isolation), and engines that are ready-but-have-advice finally say so — upgrade hints used to be dropped before reaching the UI. The model store gains a filter, disk-space context next to downloads, "in memory — safe to unload" indicators, copyable setup snippets for opt-in engines, and empty states that tell you what to do next. (#1058)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The app no longer attaches to a "zombie" backend that looks alive but fails everything.** If a backend process survived while its install was replaced or deleted underneath it, it kept answering health checks from memory — so the next launch attached to it and every real request failed with a confusing access-control error. The launcher now runs a deeper probe (an endpoint that actually touches the database) before attaching, and replaces any backend that fails it. The local dev/test scripts also now terminate running instances before wiping data, which is how this state was produced. (#1077)
|
||||
|
||||
- **The app opens at 100% scale by default.** New installs rendered everything at 130% zoom, which read as oversized on typical displays. Fresh sessions now start at native size; if you already picked a scale in Settings → Appearance, your choice is kept. (#1074)
|
||||
|
||||
- **The app no longer relaunches into a dead "generating" dub session — the blank-pane-and-spinner trap.** The saved dub session was restoring its in-flight state verbatim: quit (or crash) while a dub was generating and every subsequent launch waited forever for work that died with the process — and reinstalling couldn't clear it. Interrupted sessions now reopen on the segment editor with all your work intact (or the upload screen if nothing was transcribed yet). Thanks to @nanai97 for the screenshot that told the whole story. (#1067)
|
||||
|
||||
- **A 58-finding audit of every Settings panel, fixed end to end.** Highlights: the About page linked to the wrong project's GitHub; Arabic rendered left-to-right (RTL wiring was missing); a saved proxy could never be cleared after a reload; the HF-mirror and refinement panels vanished entirely when the backend was down; "Test now" on the HF token served five-minute-old cached results; factory reset only cleared part of what it promised; pronunciation previews ignored language-scoped entries; the hotkey recorder swallowed invalid presses in silence; Settings search could strand you with an empty sidebar — plus first component tests for previously untested panels, full i18n for five all-English panels, accessible names across inputs, confirmed destructive actions, deep links instead of dead-end advice, temp-file reclaim, and log-sharing workflows. (#1059, #1060, #1061, #1063, #1064)
|
||||
|
||||
## [0.3.16] — 2026-07-11
|
||||
|
||||
The quality release. Three long-standing frictions got structural fixes: **regenerating no longer destroys good takes** (a takes rail with starring and restore), **audiobooks stop redoing finished work** (per-sentence caching — edit one line, re-render one line; crashes resume where they stopped), and **dub translations stay consistent and fit their timeline** (auto-glossary + a naturalness pass, plus fit prediction before any GPU time is spent). Under the hood, every text path now speaks numbers, times, and abbreviations correctly, the VoxCPM2 engine gained upstream-alignment guards, and a Windows first-run breaker — model downloads completing but the cache ending up with broken file links — now self-heals automatically. Thank you @dmnobunaga for the razor-sharp diagnosis on that last one.
|
||||
|
||||
@@ -156,6 +156,97 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
return {"status": "uninstalled", "engine": engine_id, "package": pkg, "log_tail": out[-800:]}
|
||||
|
||||
|
||||
# ── One-click sidecar-engine install (IndexTTS-2 & friends) ────────────────
|
||||
#
|
||||
# Sidecar engines (dedicated venv + source checkout + weights, isolated from
|
||||
# the parent's transformers>=5.3) used to require four manual terminal steps.
|
||||
# These routes drive services.sidecar_install: POST starts a resumable
|
||||
# background job, GET polls its step-by-step status (the Settings → Engines
|
||||
# Install button polls this), DELETE removes an app-managed install.
|
||||
#
|
||||
# Path namespace: /engines/sidecar/{engine_id}/… — NOT /engines/{engine_id}/…
|
||||
# — because a dynamic segment there would shadow pre-existing literal routes
|
||||
# (this router registers before sonitranslate's, so a dynamic
|
||||
# POST /engines/{engine_id}/install would swallow
|
||||
# POST /engines/sonitranslate/install). Mirrors the
|
||||
# /engines/translation/{engine_id}/install namespace pattern.
|
||||
#
|
||||
# Loopback-gated: installing spawns subprocesses (git/uv) and writes to the
|
||||
# data directory — only the local desktop frontend may trigger it. The job
|
||||
# runs fine in packaged builds: the venv lives under the user data dir, not
|
||||
# inside the signed app bundle, and uv resolves via OMNIVOICE_BUNDLED_UV/PATH.
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def install_sidecar_engine(engine_id: str):
|
||||
"""Start (or report) the one-click install for a sidecar engine.
|
||||
|
||||
Returns ``{status: "started"|"already_running"|"already_installed"}``.
|
||||
404 for engines that have no sidecar installer — the response names the
|
||||
translation-engine route so a mis-aimed client can self-correct.
|
||||
"""
|
||||
from services import sidecar_install
|
||||
try:
|
||||
return sidecar_install.start_install(engine_id)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"No one-click installer for engine {engine_id!r}. Sidecar "
|
||||
f"installers exist for: {sorted(sidecar_install.SPECS)}. "
|
||||
"(Translation engines install via POST "
|
||||
"/engines/translation/{id}/install.)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/engines/sidecar/{engine_id}/install/status",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def sidecar_install_status(engine_id: str):
|
||||
"""Step-by-step status of the sidecar install job (poll while running).
|
||||
|
||||
Shape: ``{engine_id, installed, managed, install_dir, job}`` where job is
|
||||
null before the first run, else ``{state, steps[], log[], error,
|
||||
remediation, weights_progress, started_at, finished_at}``.
|
||||
"""
|
||||
from services import sidecar_install
|
||||
try:
|
||||
return sidecar_install.get_status(engine_id)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No one-click installer for engine {engine_id!r}.",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def uninstall_sidecar_engine(engine_id: str):
|
||||
"""Remove an app-managed sidecar install (checkout + venv + weights) and
|
||||
clear the persisted path. Refuses user-managed installs (a clone the user
|
||||
made themselves) and installs with a job still running."""
|
||||
from services import sidecar_install
|
||||
try:
|
||||
res = sidecar_install.uninstall(engine_id)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No one-click installer for engine {engine_id!r}.",
|
||||
)
|
||||
if res["status"] == "install_in_progress":
|
||||
raise HTTPException(status_code=409, detail="Install is still running — wait for it to finish.")
|
||||
if res["status"] == "not_managed":
|
||||
raise HTTPException(status_code=400, detail=res["detail"])
|
||||
return res
|
||||
|
||||
|
||||
# ── Engine health-check (Plan 02-04 / ENGINE-06) ───────────────────────────
|
||||
#
|
||||
# The Compat Matrix UI's "Test engine" button calls into this endpoint so
|
||||
|
||||
@@ -21,12 +21,17 @@ def _safe_destination(raw: str) -> str:
|
||||
status_code=400,
|
||||
detail="Export needs a destination folder. Pick where the file should go and try again.",
|
||||
)
|
||||
dest = os.path.realpath(os.path.expanduser(raw))
|
||||
if not os.path.isabs(dest):
|
||||
expanded = os.path.expanduser(raw)
|
||||
# Check BEFORE realpath(): realpath absolutizes a relative path against
|
||||
# the server's cwd, which made this check dead code — a relative
|
||||
# destination silently exported to a cwd-dependent location instead of
|
||||
# the documented 400 (regression-tested in tests/test_exports_api.py).
|
||||
if not os.path.isabs(expanded):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The destination needs to be a full path (e.g. /Users/you/Movies/OmniVoice) — not relative.",
|
||||
)
|
||||
dest = os.path.realpath(expanded)
|
||||
parent = os.path.dirname(dest)
|
||||
if not parent or not os.path.isdir(parent):
|
||||
raise HTTPException(
|
||||
@@ -39,7 +44,10 @@ def _safe_destination(raw: str) -> str:
|
||||
def _safe_source(filename: str) -> str:
|
||||
"""Resolve a source filename against OUTPUTS_DIR / dub outputs, blocking traversal."""
|
||||
base = os.path.basename(filename or "")
|
||||
if not base or base != filename:
|
||||
# "." and ".." are their own basename, so they'd slip past the
|
||||
# base != filename check and only die later on realpath containment —
|
||||
# reject them up front with the same 400 as any other malformed name.
|
||||
if not base or base != filename or base in (".", ".."):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The file to export has an unexpected name. Try re-generating the audio and exporting again.",
|
||||
|
||||
@@ -161,13 +161,18 @@ async def search_youtube(
|
||||
list. Users are responsible for the licensing of whatever they import.
|
||||
"""
|
||||
try:
|
||||
# yt-dlp is an importable module, never a PATH requirement — run it
|
||||
# via the interpreter (honors the Settings → Audio tools overlay).
|
||||
from services.media_tools import ytdlp_invocation
|
||||
ytdlp_argv, ytdlp_env = ytdlp_invocation()
|
||||
result = await spawn_subprocess(
|
||||
"yt-dlp",
|
||||
*ytdlp_argv,
|
||||
"--dump-json",
|
||||
"--remote-components", "ejs:github",
|
||||
f"ytsearch{max_results}:{query}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=ytdlp_env,
|
||||
)
|
||||
stdout, stderr = await result.communicate()
|
||||
|
||||
@@ -218,8 +223,10 @@ async def download_youtube_clip(
|
||||
temp_path = str(VOICE_GALLERY_DIR / f"{voice_id}.%(ext)s")
|
||||
|
||||
try:
|
||||
from services.media_tools import ytdlp_invocation
|
||||
ytdlp_argv, ytdlp_env = ytdlp_invocation()
|
||||
cmd = [
|
||||
"yt-dlp",
|
||||
*ytdlp_argv,
|
||||
"--remote-components", "ejs:github",
|
||||
"-f",
|
||||
"bestaudio",
|
||||
@@ -239,6 +246,7 @@ async def download_youtube_clip(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=ytdlp_env,
|
||||
)
|
||||
stdout, stderr = await result.communicate()
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Media-tools endpoints — the backend for Settings → Audio tools and the
|
||||
wizard's invisible media-engine self-heal.
|
||||
|
||||
Every route is loopback-gated: ``custom-path`` / ``use-system`` point the app
|
||||
at an arbitrary executable (an RCE primitive if remote-reachable), and the
|
||||
rest mutate local state. Same contract as ``/system/set-env``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
router = APIRouter(dependencies=[Depends(require_loopback)])
|
||||
|
||||
|
||||
class CustomPathRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
def _svc():
|
||||
# Late import so a service-level failure surfaces as a 500 with detail,
|
||||
# not an app-boot failure.
|
||||
from services import media_tools
|
||||
return media_tools
|
||||
|
||||
|
||||
@router.get("/media-tools/status")
|
||||
def media_tools_status():
|
||||
"""Per-tool {ok, path, version, origin} + background-op states."""
|
||||
return _svc().status()
|
||||
|
||||
|
||||
@router.post("/media-tools/acquire")
|
||||
def media_tools_acquire():
|
||||
"""(Re-)fetch the pinned, checksummed static ffmpeg/ffprobe build in the
|
||||
background. Idempotent; poll /media-tools/status for progress."""
|
||||
return _svc().acquire_bundled()
|
||||
|
||||
|
||||
# Literal ytdlp routes MUST register before the parametrized {tool} routes —
|
||||
# FastAPI matches in declaration order, and `/media-tools/{tool}/restore`
|
||||
# would otherwise swallow `/media-tools/ytdlp/restore` into a 400.
|
||||
@router.post("/media-tools/ytdlp/update")
|
||||
def media_tools_ytdlp_update():
|
||||
"""Fetch the newest yt-dlp wheel (sha256-verified against PyPI metadata)
|
||||
into the update-surviving overlay. Applies on next backend start."""
|
||||
return _svc().update_ytdlp()
|
||||
|
||||
|
||||
@router.post("/media-tools/ytdlp/restore")
|
||||
def media_tools_ytdlp_restore():
|
||||
"""Drop the overlay — the app-tested, locked yt-dlp takes over on next
|
||||
start. Always safe (the locked install is never modified)."""
|
||||
return _svc().restore_ytdlp()
|
||||
|
||||
|
||||
@router.post("/media-tools/{tool}/custom-path")
|
||||
def media_tools_custom_path(tool: str, body: CustomPathRequest):
|
||||
try:
|
||||
return _svc().set_custom_path(tool, body.path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/media-tools/{tool}/use-system")
|
||||
def media_tools_use_system(tool: str):
|
||||
try:
|
||||
return _svc().use_system(tool)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except LookupError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/media-tools/{tool}/restore")
|
||||
def media_tools_restore(tool: str):
|
||||
try:
|
||||
return _svc().restore_bundled(tool)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -77,8 +77,17 @@ def clear_hf_token(also_clear_hf_cli: bool = Query(False)):
|
||||
|
||||
|
||||
@router.get("/hf-token/state")
|
||||
def get_hf_token_state():
|
||||
"""3-source HF token cascade state for the Settings UI."""
|
||||
def get_hf_token_state(fresh: bool = Query(False)):
|
||||
"""3-source HF token cascade state for the Settings UI.
|
||||
|
||||
``fresh=1`` drops the resolver's whoami validation cache first so the
|
||||
response re-runs whoami for every source — this is what the panel's
|
||||
"Test now" button sends. Plain GETs (panel mounts) keep the 300s cache
|
||||
so repeat Settings visits don't hammer the HF API.
|
||||
"""
|
||||
from services import token_resolver
|
||||
if fresh:
|
||||
token_resolver.invalidate_cache()
|
||||
return _state_response()
|
||||
|
||||
|
||||
@@ -725,6 +734,26 @@ async def get_storage_report(refresh: bool = Query(False)):
|
||||
raise HTTPException(status_code=500, detail="Failed to compute storage report")
|
||||
|
||||
|
||||
@router.post("/storage/temp/clear")
|
||||
async def clear_temp_files():
|
||||
"""Delete OmniVoice-owned temp files (Settings → Storage → Temporary files).
|
||||
|
||||
Removes only the ``omnivoice*`` entries in the OS temp dir — the exact
|
||||
population the storage report's "temp" category counts — and invalidates
|
||||
the cached report so the next scan reflects the reclaimed space. Partial
|
||||
failures (files held open by a running job) are returned per entry.
|
||||
"""
|
||||
from services import storage_report
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(storage_report.clear_temp)
|
||||
storage_report.clear_cache()
|
||||
return result
|
||||
except Exception:
|
||||
logger.exception("clear temp files failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to clear temporary files")
|
||||
|
||||
|
||||
# ── HF mirror endpoint (parity program Wave 4.3 / §R4 c) ──────────────────
|
||||
# Restricted-network users (e.g. behind the Great Firewall) need to point
|
||||
# huggingface_hub at a mirror. HF reads HF_ENDPOINT at import time, so a
|
||||
@@ -744,27 +773,63 @@ _HF_MIRROR_PRESETS = [
|
||||
|
||||
class _HFMirrorBody(BaseModel):
|
||||
url: str = Field("", description="HF_ENDPOINT URL; empty string clears it (official endpoint)")
|
||||
mode: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"'auto' switches to automatic endpoint selection (clears any "
|
||||
"explicit endpoint); 'manual' (or omitted — back-compat with older "
|
||||
"clients) pins the given url as an explicit choice."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/hf-mirror")
|
||||
def get_hf_mirror():
|
||||
def _hf_mirror_state() -> dict:
|
||||
"""The full GET /hf-mirror payload. Auto info comes from the CACHED race
|
||||
decision only — reading settings never probes the network."""
|
||||
from core import user_env
|
||||
from services import endpoint_race
|
||||
|
||||
configured = user_env.get_user_env(_HF_ENDPOINT_ENV) or ""
|
||||
try:
|
||||
mode = "manual" if configured else endpoint_race.mode()
|
||||
auto = endpoint_race.cached_decision() if mode == "auto" else None
|
||||
opt_out = endpoint_race.env_opt_out()
|
||||
except Exception: # a broken prefs file must never 500 the settings page
|
||||
logger.exception("hf-mirror auto state unavailable")
|
||||
mode, auto, opt_out = "manual", None, False
|
||||
return {
|
||||
# The value that will apply after restart (persisted), and what's
|
||||
# live in this process (env may differ until then).
|
||||
"configured": configured,
|
||||
"effective": os.environ.get(_HF_ENDPOINT_ENV, ""),
|
||||
"presets": _HF_MIRROR_PRESETS,
|
||||
# Automatic endpoint selection (services.endpoint_race): "auto" only
|
||||
# when nothing explicit is configured anywhere. `auto` is the cached
|
||||
# race decision ({endpoint, reachable, latency_ms, checked_at,
|
||||
# results}) or null when never raced / in manual mode.
|
||||
"mode": mode,
|
||||
"auto": auto,
|
||||
"auto_opt_out": opt_out,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hf-mirror")
|
||||
def get_hf_mirror():
|
||||
return _hf_mirror_state()
|
||||
|
||||
|
||||
@router.put("/hf-mirror")
|
||||
def set_hf_mirror(body: _HFMirrorBody):
|
||||
from core import user_env
|
||||
from services import endpoint_race
|
||||
|
||||
url = (body.url or "").strip().rstrip("/")
|
||||
mode = (body.mode or "manual").strip().lower()
|
||||
if mode not in {"auto", "manual"}:
|
||||
raise HTTPException(status_code=400, detail="mode must be 'auto' or 'manual'")
|
||||
# Auto mode = no explicit endpoint anywhere; a persisted endpoint would
|
||||
# read as an explicit choice, so switching to Auto clears it (plus the
|
||||
# `hf_endpoint` pref fallback the download paths resolve).
|
||||
url = "" if mode == "auto" else (body.url or "").strip().rstrip("/")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="Mirror URL must start with http(s)://")
|
||||
# Compare against the currently-persisted value (normalised the same way) so
|
||||
@@ -779,15 +844,44 @@ def set_hf_mirror(body: _HFMirrorBody):
|
||||
else:
|
||||
user_env.unset_user_env(_HF_ENDPOINT_ENV)
|
||||
os.environ.pop(_HF_ENDPOINT_ENV, None)
|
||||
from core import prefs
|
||||
if mode == "auto":
|
||||
prefs.delete("hf_endpoint") # the pref fallback is explicit config too
|
||||
endpoint_race.set_mode_pref(mode)
|
||||
except Exception:
|
||||
logger.exception("set_hf_mirror failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist mirror setting")
|
||||
if mode == "auto":
|
||||
# Freshly chosen Auto should show a real pick immediately — race now
|
||||
# unless a fresh cached decision already exists (probes are ≤3 s and
|
||||
# this is an explicit user action, not a hot path).
|
||||
try:
|
||||
endpoint_race.ensure_decision()
|
||||
except Exception:
|
||||
logger.exception("endpoint race after switching to auto failed")
|
||||
# Model Store downloads pick up the new mirror immediately — the download
|
||||
# path resolves the endpoint per-call and we updated os.environ above. Only
|
||||
# transformers-side model *loads* (which read HF_ENDPOINT at import time)
|
||||
# need a restart, so restart_required is True ONLY when the value actually
|
||||
# changed — a no-op re-save never asks for a restart.
|
||||
return {"configured": url, "restart_required": changed, "presets": _HF_MIRROR_PRESETS}
|
||||
return {**_hf_mirror_state(), "restart_required": changed}
|
||||
|
||||
|
||||
@router.post("/hf-mirror/test")
|
||||
def test_hf_mirror():
|
||||
"""Re-run the endpoint race now (the Auto panel's "Test again").
|
||||
|
||||
Forces fresh probes and re-caches the decision. In manual mode this is a
|
||||
no-op (an explicit endpoint is never auto-switched) — the response simply
|
||||
reflects the current state."""
|
||||
from services import endpoint_race
|
||||
|
||||
try:
|
||||
endpoint_race.ensure_decision(force=True)
|
||||
except Exception:
|
||||
logger.exception("hf-mirror endpoint test failed")
|
||||
raise HTTPException(status_code=500, detail="Endpoint test failed")
|
||||
return _hf_mirror_state()
|
||||
|
||||
|
||||
# ── OpenAI-compatible remote ASR (#877) ─────────────────────────────────────
|
||||
|
||||
@@ -72,12 +72,15 @@ def _download_max_workers() -> int:
|
||||
|
||||
|
||||
def _download_endpoint() -> "str | None":
|
||||
"""Optional HF endpoint override (FDL-10 mirror path, opt-in). Returned as a
|
||||
per-call ``endpoint=`` rather than a process-wide HF_ENDPOINT mutation. A
|
||||
"""Optional HF endpoint override, per-call ``endpoint=`` rather than a
|
||||
process-wide HF_ENDPOINT mutation. Explicit configuration (FDL-10 mirror
|
||||
path: HF_ENDPOINT env / ``hf_endpoint`` pref / Settings) always wins; when
|
||||
nothing was chosen, the automatic endpoint selection's cached pick applies
|
||||
(services.endpoint_race — probe-based, cached, never probes here). A
|
||||
mirror routes through the classic LFS path (no Xet) — documented in
|
||||
docs/downloading-models.md."""
|
||||
ep = prefs.resolve("hf_endpoint", env="HF_ENDPOINT", default=None)
|
||||
return ep or None
|
||||
from services import endpoint_race
|
||||
return endpoint_race.effective_endpoint()
|
||||
|
||||
|
||||
def apply_xet_env() -> None:
|
||||
@@ -488,6 +491,23 @@ async def install_model(req: InstallModelRequest):
|
||||
"model install %s: attempt %d/%d failed (%s); retry in %ds",
|
||||
req.repo_id, _attempt, _max_attempts, net_err, _backoff,
|
||||
)
|
||||
# Endpoint failover (auto mode only, once per repo per
|
||||
# process): a network-classified failure re-races the
|
||||
# endpoints and, when the winner changed, the next attempt
|
||||
# retries on it — so a mid-download endpoint outage heals
|
||||
# instead of burning every retry on a dead host. Explicit
|
||||
# user endpoints are never switched.
|
||||
from services import endpoint_race
|
||||
if endpoint_race.reselect_after_failure(req.repo_id, str(net_err)):
|
||||
_endpoint = _download_endpoint()
|
||||
if _endpoint:
|
||||
dl_kwargs["endpoint"] = _endpoint
|
||||
else:
|
||||
dl_kwargs.pop("endpoint", None)
|
||||
logger.info(
|
||||
"model install %s: endpoint failover — retrying on %s",
|
||||
req.repo_id, _endpoint or "https://huggingface.co",
|
||||
)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Extracted from the monolithic ``setup.py``.
|
||||
|
||||
- ``GET /setup/status`` — missing-model gate for boot screen
|
||||
- ``GET /setup/preflight`` — system health check (OS, RAM, GPU, ffmpeg…)
|
||||
- ``GET /setup/preflight`` — system health check (OS, RAM, disk, GPU, network —
|
||||
genuine user facts only; the media engine (ffmpeg/ffprobe/yt-dlp) is an
|
||||
internal concern that self-heals via ``services.media_tools``)
|
||||
- ``POST /setup/warmup`` — background model pre-load
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -12,7 +14,6 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import platform as _platform
|
||||
import shutil as _shutil
|
||||
import sys
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -202,6 +203,114 @@ def _hf_endpoint_host() -> tuple[str, int]:
|
||||
return "huggingface.co", 443
|
||||
|
||||
|
||||
def _network_check() -> dict:
|
||||
"""The preflight "network" check row — auto-race or explicit-endpoint probe.
|
||||
|
||||
Auto mode (nothing explicitly configured): force a fresh endpoint race —
|
||||
preflight IS the connectivity health check, and the cached winner is what
|
||||
model downloads will use. Manual mode: probe exactly the configured
|
||||
endpoint (never auto-switch an explicit choice), keeping the mirror
|
||||
quick-pick affordance when the official endpoint is blocked.
|
||||
"""
|
||||
auto_decision = None
|
||||
try:
|
||||
from services import endpoint_race
|
||||
if endpoint_race.mode() == "auto":
|
||||
auto_decision = endpoint_race.ensure_decision(force=True)
|
||||
except Exception as exc: # the race must never break preflight
|
||||
logger.warning("preflight endpoint race failed: %s", exc)
|
||||
|
||||
if auto_decision is not None:
|
||||
from urllib.parse import urlsplit
|
||||
from services.endpoint_race import CANONICAL_ENDPOINT
|
||||
|
||||
picked = auto_decision["endpoint"]
|
||||
picked_host = urlsplit(picked).hostname or picked
|
||||
latency = auto_decision.get("latency_ms")
|
||||
latency_s = f" ({latency:.0f} ms)" if isinstance(latency, (int, float)) else ""
|
||||
results = {r["endpoint"]: r for r in auto_decision.get("results", [])}
|
||||
canonical_ok = bool(results.get(CANONICAL_ENDPOINT, {}).get("reachable"))
|
||||
mirror_reachable = any(
|
||||
r.get("reachable") for ep, r in results.items() if ep != CANONICAL_ENDPOINT
|
||||
)
|
||||
if auto_decision.get("reachable"):
|
||||
if picked == CANONICAL_ENDPOINT:
|
||||
detail = f"Reachable{latency_s}"
|
||||
elif not canonical_ok:
|
||||
detail = (
|
||||
f"huggingface.co is unreachable on this network — using the "
|
||||
f"community mirror {picked_host}{latency_s} for model "
|
||||
"downloads. Downloads are checksum-verified by Hugging Face "
|
||||
"regardless of endpoint; change anytime in Settings → "
|
||||
"Models → Hugging Face mirror."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
f"Both endpoints reachable — {picked_host}{latency_s} "
|
||||
"selected (decisively faster here). Change anytime in "
|
||||
"Settings → Models → Hugging Face mirror."
|
||||
)
|
||||
status, fix = "pass", None
|
||||
else:
|
||||
status = "warn"
|
||||
detail = "No Hugging Face endpoint reachable"
|
||||
fix = (
|
||||
"Neither huggingface.co nor the hf-mirror.com community mirror "
|
||||
"responded — check internet connection, VPN, or firewall. You "
|
||||
"can continue — models already downloaded keep working "
|
||||
"offline; a custom mirror can be configured below."
|
||||
)
|
||||
return {
|
||||
"id": "network", "label": f"Network ({picked_host})",
|
||||
"status": status, "detail": detail, "fix": fix,
|
||||
# Frontend affordance hint: the wizard offers the mirror
|
||||
# quick-pick when the check didn't pass (PreflightCheck allows
|
||||
# extras). `endpoint` documents the auto pick for the UI.
|
||||
"mirror_reachable": mirror_reachable,
|
||||
"endpoint": picked,
|
||||
}
|
||||
|
||||
# Manual mode (explicit endpoint) — probe exactly what the user chose.
|
||||
net_host, net_port = _hf_endpoint_host()
|
||||
net_ok = _probe_network(net_host, net_port)
|
||||
mirror_reachable = False
|
||||
if not net_ok and net_host == "huggingface.co":
|
||||
# Official endpoint blocked — if the community mirror is reachable,
|
||||
# tell the user exactly which switch unblocks them.
|
||||
mirror_reachable = _probe_network("hf-mirror.com")
|
||||
if net_ok:
|
||||
net_fix = None
|
||||
elif mirror_reachable:
|
||||
net_fix = (
|
||||
"huggingface.co is blocked on this network, but the hf-mirror.com "
|
||||
"community mirror is reachable — apply it below and re-check. "
|
||||
"Model downloads will use the mirror immediately."
|
||||
)
|
||||
elif net_host != "huggingface.co":
|
||||
net_fix = (
|
||||
f"Your configured Hugging Face mirror ({net_host}) is unreachable "
|
||||
"— it may be down or blocked. Pick another mirror or the official "
|
||||
"endpoint below, or continue offline: models already downloaded "
|
||||
"keep working."
|
||||
)
|
||||
else:
|
||||
net_fix = (
|
||||
"Check internet connection, VPN, or corporate firewall whitelist "
|
||||
"for huggingface.co. You can continue — models already downloaded "
|
||||
"keep working offline; new downloads need a connection or a "
|
||||
"mirror (configurable below)."
|
||||
)
|
||||
return {
|
||||
"id": "network", "label": f"Network ({net_host})",
|
||||
"status": "pass" if net_ok else "warn",
|
||||
"detail": "Reachable" if net_ok else f"Unreachable on port {net_port}",
|
||||
"fix": net_fix,
|
||||
# Frontend affordance hint: the wizard offers the mirror quick-pick
|
||||
# when the endpoint is unreachable (PreflightCheck allows extras).
|
||||
"mirror_reachable": mirror_reachable,
|
||||
}
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
try:
|
||||
import psutil
|
||||
@@ -280,59 +389,19 @@ def preflight():
|
||||
f"Fix write permissions on {cache} or point HF_HOME elsewhere.",
|
||||
})
|
||||
|
||||
# ── FFmpeg
|
||||
ffmpeg_path = None
|
||||
# ── Media engine (ffmpeg/ffprobe/yt-dlp) — deliberately NOT a check row.
|
||||
# These are internal dependencies the app provisions for itself, not user
|
||||
# facts: when the resolution chain has no tier at all, preflight kicks the
|
||||
# bundled acquisition in the background and the wizard shows a quiet
|
||||
# progress line (a failure card only if that fails — with Retry / use a
|
||||
# system copy). yt-dlp is an importable locked module and never appears.
|
||||
# Power users manage all three in Settings → Audio tools.
|
||||
media_tools = None
|
||||
try:
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
except Exception as e:
|
||||
checks.append({
|
||||
"id": "ffmpeg", "label": "FFmpeg", "status": "fail",
|
||||
"detail": str(e)[:200],
|
||||
"fix": "Install ffmpeg via your package manager "
|
||||
"(brew install ffmpeg / apt install ffmpeg / choco install ffmpeg).",
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "ffmpeg", "label": "FFmpeg", "status": "pass",
|
||||
"detail": ffmpeg_path, "fix": None,
|
||||
})
|
||||
|
||||
# ── FFprobe
|
||||
ffprobe_path = None
|
||||
try:
|
||||
from services.ffmpeg_utils import find_ffprobe
|
||||
ffprobe_path = find_ffprobe()
|
||||
except Exception:
|
||||
pass
|
||||
if ffprobe_path:
|
||||
checks.append({
|
||||
"id": "ffprobe", "label": "FFprobe", "status": "pass",
|
||||
"detail": ffprobe_path, "fix": None,
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "ffprobe", "label": "FFprobe", "status": "warn",
|
||||
"detail": "Not bundled alongside ffmpeg.",
|
||||
"fix": "File-probe endpoint (/tools/probe) will 501. "
|
||||
"Install system ffmpeg (includes ffprobe) to enable it.",
|
||||
})
|
||||
|
||||
# ── yt-dlp
|
||||
yt_dlp_path = _shutil.which("yt-dlp")
|
||||
if yt_dlp_path:
|
||||
rc_ytv, yt_ver = _run_cmd([yt_dlp_path, "--version"], timeout=3.0)
|
||||
yt_version = yt_ver.strip() if rc_ytv == 0 else "unknown"
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "pass",
|
||||
"detail": f"{yt_dlp_path} (v{yt_version})", "fix": None,
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "warn",
|
||||
"detail": "Not found in system PATH.",
|
||||
"fix": "YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
|
||||
})
|
||||
from services.media_tools import summary as _media_summary
|
||||
media_tools = _media_summary(auto_acquire=True)
|
||||
except Exception as exc: # never break preflight on the media engine
|
||||
logger.warning("preflight media_tools summary failed: %s", exc)
|
||||
|
||||
# ── GPU
|
||||
gpu = _detect_gpu()
|
||||
@@ -424,50 +493,21 @@ def preflight():
|
||||
"status": r_status, "detail": r_detail, "fix": r_fix,
|
||||
})
|
||||
|
||||
# ── Network — probes the HF endpoint actually in effect (mirror-aware),
|
||||
# and a dead network is a WARNING, not a blocker. The app is local-first:
|
||||
# already-downloaded models work offline, and a hard fail here dead-ends
|
||||
# restricted-network users (e.g. China, where huggingface.co is blocked)
|
||||
# on the very first screen — before they can reach the mirror setting
|
||||
# that fixes it. Model downloads surface their own actionable errors.
|
||||
net_host, net_port = _hf_endpoint_host()
|
||||
net_ok = _probe_network(net_host, net_port)
|
||||
mirror_reachable = False
|
||||
if not net_ok and net_host == "huggingface.co":
|
||||
# Official endpoint blocked — if the community mirror is reachable,
|
||||
# tell the user exactly which switch unblocks them.
|
||||
mirror_reachable = _probe_network("hf-mirror.com")
|
||||
if net_ok:
|
||||
net_fix = None
|
||||
elif mirror_reachable:
|
||||
net_fix = (
|
||||
"huggingface.co is blocked on this network, but the hf-mirror.com "
|
||||
"community mirror is reachable — apply it below and re-check. "
|
||||
"Model downloads will use the mirror immediately."
|
||||
)
|
||||
elif net_host != "huggingface.co":
|
||||
net_fix = (
|
||||
f"Your configured Hugging Face mirror ({net_host}) is unreachable "
|
||||
"— it may be down or blocked. Pick another mirror or the official "
|
||||
"endpoint below, or continue offline: models already downloaded "
|
||||
"keep working."
|
||||
)
|
||||
else:
|
||||
net_fix = (
|
||||
"Check internet connection, VPN, or corporate firewall whitelist "
|
||||
"for huggingface.co. You can continue — models already downloaded "
|
||||
"keep working offline; new downloads need a connection or a "
|
||||
"mirror (configurable below)."
|
||||
)
|
||||
checks.append({
|
||||
"id": "network", "label": f"Network ({net_host})",
|
||||
"status": "pass" if net_ok else "warn",
|
||||
"detail": "Reachable" if net_ok else f"Unreachable on port {net_port}",
|
||||
"fix": net_fix,
|
||||
# Frontend affordance hint: the wizard offers the mirror quick-pick
|
||||
# when the endpoint is unreachable (PreflightCheck allows extras).
|
||||
"mirror_reachable": mirror_reachable,
|
||||
})
|
||||
# ── Network — a dead network is a WARNING, not a blocker. The app is
|
||||
# local-first: already-downloaded models work offline, and a hard fail
|
||||
# here dead-ends restricted-network users (e.g. China, where
|
||||
# huggingface.co is blocked) on the very first screen — before they can
|
||||
# reach the mirror setting that fixes it. Model downloads surface their
|
||||
# own actionable errors.
|
||||
#
|
||||
# With NO explicit endpoint configured, preflight runs the automatic
|
||||
# endpoint race (services.endpoint_race): both the official endpoint and
|
||||
# the community mirror are probed, the winner is cached for downloads,
|
||||
# and the copy states the outcome honestly — so a blocked huggingface.co
|
||||
# no longer needs the user to find the mirror setting at all. An explicit
|
||||
# endpoint (Settings / HF_ENDPOINT / pref) keeps the single-endpoint
|
||||
# probe: the user's choice is never auto-switched.
|
||||
checks.append(_network_check())
|
||||
|
||||
# Aggregate
|
||||
any_fail = any(c["status"] == "fail" for c in checks)
|
||||
@@ -492,6 +532,7 @@ def preflight():
|
||||
"disk_free_gb": round(free, 1),
|
||||
},
|
||||
"gpu_routing": gpu_routing,
|
||||
"media_tools": media_tools,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -629,15 +629,17 @@ def system_notifications():
|
||||
notes.append({
|
||||
"id": "ffmpeg-missing",
|
||||
"level": "error",
|
||||
"title": "ffmpeg not found",
|
||||
"title": "Media engine unavailable",
|
||||
"message": (
|
||||
"Video processing, audio conversion, and dubbing require ffmpeg. "
|
||||
"Install it with: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)."
|
||||
"Video processing, audio conversion, and dubbing need the "
|
||||
"media engine (ffmpeg), which the app normally provisions "
|
||||
"itself. Open Settings > Audio tools and press Restore "
|
||||
"bundled to re-download it, or point it at a system copy."
|
||||
),
|
||||
"action": {
|
||||
"label": "Install guide",
|
||||
"type": "link",
|
||||
"target": "https://ffmpeg.org/download.html",
|
||||
"label": "Open Audio tools",
|
||||
"type": "settings-tab",
|
||||
"target": "audio-tools",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -752,6 +754,17 @@ PERSISTENT_KEYS = {
|
||||
"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT",
|
||||
}
|
||||
|
||||
# Sidecar-engine install dirs (OMNIVOICE_INDEXTTS_DIR, …). The one-click
|
||||
# installer persists these via prefs.json `env.*` (restored at startup in
|
||||
# main.py); merging them here lets users inspect/clear them from the same
|
||||
# Settings env panel as every other persisted var. Single-sourced from the
|
||||
# installer's SPECS so a future sidecar engine can't forget to register.
|
||||
try:
|
||||
from services.sidecar_install import persistent_env_vars as _sidecar_env_vars
|
||||
PERSISTENT_KEYS |= _sidecar_env_vars()
|
||||
except Exception: # pragma: no cover — defensive: env panel > installer wiring
|
||||
pass
|
||||
|
||||
# Keys whose value must be a valid TCP port (1024–65535). Validated before
|
||||
# being set so a bad value never reaches uvicorn / the share listener.
|
||||
_PORT_KEYS = {"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT"}
|
||||
|
||||
@@ -164,6 +164,11 @@ class PreflightResponse(BaseModel):
|
||||
# Explicit field (PreflightResponse has no extra="allow") so the verdict
|
||||
# survives serialization instead of being silently dropped.
|
||||
gpu_routing: GpuRouting | None = None
|
||||
# Media-engine verdict (ffmpeg/ffprobe) — NOT a check row: an internal
|
||||
# dependency the app provisions for itself. Shape: {ready, acquire:
|
||||
# {state, progress, error}}. The wizard renders a quiet progress line /
|
||||
# failure card from it instead of "install ffmpeg" system requirements.
|
||||
media_tools: dict | None = None
|
||||
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
|
||||
@@ -88,17 +88,33 @@ def _check_device() -> dict:
|
||||
|
||||
|
||||
def _check_ffmpeg() -> dict:
|
||||
"""Media engine (ffmpeg + ffprobe) — an internal dependency the app
|
||||
bundles/acquires itself, so a failure here means the self-heal also has
|
||||
nothing to work with (and the hint says where the controls live)."""
|
||||
ffmpeg = ffprobe = None
|
||||
try:
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
path = find_ffmpeg()
|
||||
from services.ffmpeg_utils import find_ffmpeg, find_ffprobe
|
||||
ffmpeg = find_ffmpeg()
|
||||
ffprobe = find_ffprobe()
|
||||
except Exception:
|
||||
path = None
|
||||
if path:
|
||||
return _check("ffmpeg", "ffmpeg", OK, str(path))
|
||||
pass
|
||||
if ffmpeg and ffprobe:
|
||||
return _check("ffmpeg", "Media engine (ffmpeg)", OK,
|
||||
f"ffmpeg: {ffmpeg}; ffprobe: {ffprobe}")
|
||||
if ffmpeg:
|
||||
return _check(
|
||||
"ffmpeg", "Media engine (ffmpeg)", WARN,
|
||||
f"ffmpeg: {ffmpeg}; ffprobe missing",
|
||||
"Media probing (Smart Fit, file inspection) is degraded. Open "
|
||||
"Settings > Audio tools and press Restore bundled to fetch the "
|
||||
"app's own ffprobe, or point it at a system copy there.",
|
||||
)
|
||||
return _check(
|
||||
"ffmpeg", "ffmpeg", FAIL,
|
||||
"not found on PATH or FFMPEG_PATH",
|
||||
"Dubbing and audio conversion need ffmpeg: brew install ffmpeg (macOS), apt install ffmpeg (Linux), or set the path in Settings > General.",
|
||||
"ffmpeg", "Media engine (ffmpeg)", FAIL,
|
||||
"no runnable ffmpeg in any tier (sidecar, bundled, system, custom)",
|
||||
"Dubbing and audio conversion are unavailable. The app normally "
|
||||
"provisions ffmpeg itself — open Settings > Audio tools and press "
|
||||
"Restore bundled (needs network once), or choose a system copy there.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+13
-1
@@ -102,6 +102,18 @@ _HF_CONTEXT_MARKERS = (
|
||||
)
|
||||
|
||||
|
||||
def is_hf_connectivity_error(reason: Optional[str]) -> bool:
|
||||
"""True when *reason* looks like a network/connectivity failure of an HF
|
||||
download (DNS, refused/reset connections, timeouts, hub locate errors).
|
||||
|
||||
Signature match only — callers already in a download context (the model
|
||||
install worker, the cache auto-repair, the endpoint failover in
|
||||
services.endpoint_race) don't need the HF-context markers that
|
||||
``hf_mirror_hint`` requires. Never raises."""
|
||||
low = (reason or "").lower()
|
||||
return any(sig in low for sig in _HF_CONNECTIVITY_SIGNATURES)
|
||||
|
||||
|
||||
def configured_hf_mirror() -> str:
|
||||
"""The non-default Hugging Face endpoint (mirror) in effect, or "".
|
||||
|
||||
@@ -138,7 +150,7 @@ def hf_mirror_hint(reason: Optional[str]) -> str:
|
||||
if not mirror:
|
||||
return ""
|
||||
low = (reason or "").lower()
|
||||
if not any(sig in low for sig in _HF_CONNECTIVITY_SIGNATURES):
|
||||
if not is_hf_connectivity_error(low):
|
||||
return ""
|
||||
try:
|
||||
host = (urlsplit(mirror).netloc or "").lower()
|
||||
|
||||
+17
-6
@@ -15,6 +15,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.config import DATA_DIR
|
||||
@@ -23,6 +24,14 @@ logger = logging.getLogger("omnivoice.prefs")
|
||||
|
||||
_PREFS_PATH = os.path.join(DATA_DIR, "prefs.json")
|
||||
|
||||
# Serializes the load-modify-save cycle of every mutation. Writers run on
|
||||
# many threads (FastAPI's request threadpool, background workers like the
|
||||
# sidecar-engine installer); without the lock two concurrent set_/delete
|
||||
# calls interleave their read-modify-write and the later save silently
|
||||
# drops the other's key. Reads stay lock-free — the atomic os.replace in
|
||||
# _save guarantees they never see a torn file.
|
||||
_MUTATE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
@@ -60,16 +69,18 @@ def get(key: str, default: Any = None) -> Any:
|
||||
|
||||
|
||||
def set_(key: str, value: Any) -> None:
|
||||
data = _load()
|
||||
data[key] = value
|
||||
_save(data)
|
||||
with _MUTATE_LOCK:
|
||||
data = _load()
|
||||
data[key] = value
|
||||
_save(data)
|
||||
|
||||
|
||||
def delete(key: str) -> None:
|
||||
"""Remove *key* from prefs.json if present."""
|
||||
data = _load()
|
||||
data.pop(key, None)
|
||||
_save(data)
|
||||
with _MUTATE_LOCK:
|
||||
data = _load()
|
||||
data.pop(key, None)
|
||||
_save(data)
|
||||
|
||||
|
||||
def resolve(key: str, *, env: Optional[str] = None, default: Any = None) -> Any:
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
|
||||
# release.yml's version-bump job, so it stays equal to
|
||||
# pyproject/tauri.conf/Cargo/package.json.
|
||||
_FALLBACK_VERSION = "0.3.16"
|
||||
_FALLBACK_VERSION = "0.3.18"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -41,9 +41,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -145,11 +143,12 @@ def _venv_python_path(venv_dir: Path) -> Path:
|
||||
"""Return the python executable path inside a venv directory.
|
||||
|
||||
Handles the Unix (``bin/python``) vs Windows (``Scripts/python.exe``)
|
||||
layout. No filesystem access — caller checks .is_file().
|
||||
layout. No filesystem access — caller checks .is_file(). Delegates to
|
||||
the canonical implementation in :mod:`services.sidecar_install` so the
|
||||
cross-platform venv-layout rule lives in exactly one place.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return venv_dir / "Scripts" / "python.exe"
|
||||
return venv_dir / "bin" / "python"
|
||||
from services.sidecar_install import _venv_python
|
||||
return _venv_python(venv_dir)
|
||||
|
||||
|
||||
def _probe_paths() -> list[Path]:
|
||||
@@ -189,14 +188,14 @@ def _venv_can_import_indextts(python_path: Path) -> bool:
|
||||
|
||||
|
||||
def _locate_uv() -> Optional[str]:
|
||||
"""Find the uv binary — bundled first (Tauri-set env var), else PATH."""
|
||||
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
|
||||
if bundled and Path(bundled).is_file():
|
||||
return bundled
|
||||
sys_uv = shutil.which("uv")
|
||||
if sys_uv:
|
||||
return sys_uv
|
||||
return None
|
||||
"""Find the uv binary — bundled first (Tauri-set env var), else PATH.
|
||||
|
||||
Delegates to :mod:`services.sidecar_install`'s canonical resolver so the
|
||||
bundled-uv contract (env var name, precedence) can't drift between this
|
||||
lazy bootstrap and the one-click installer.
|
||||
"""
|
||||
from services.sidecar_install import _locate_uv as _canonical_locate_uv
|
||||
return _canonical_locate_uv()
|
||||
|
||||
|
||||
def _bootstrap_engines_venv(indextts_clone: Path) -> Path:
|
||||
|
||||
@@ -195,6 +195,18 @@ try:
|
||||
except Exception:
|
||||
pass # prefs.json missing or broken — fine on first run
|
||||
|
||||
# ── Activate the yt-dlp user-update overlay (Settings → Audio tools) ──────
|
||||
# Must run before anything imports yt_dlp so a user-updated version (stored
|
||||
# under DATA_DIR, surviving app updates and uv drift syncs) wins over the
|
||||
# locked wheel. Best-effort: a broken overlay must never block startup.
|
||||
try:
|
||||
from services.media_tools import activate_ytdlp_overlay
|
||||
activate_ytdlp_overlay()
|
||||
except Exception:
|
||||
# Best-effort by design: a broken/corrupt overlay must never block
|
||||
# startup — the locked wheel on sys.path is the fallback.
|
||||
pass
|
||||
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
torchaudio.set_audio_backend("soundfile")
|
||||
|
||||
@@ -375,6 +387,7 @@ from api.routers import (
|
||||
longform_jobs,
|
||||
pronunciation, # Expressive-TTS Spec 01: user pronunciation dictionary
|
||||
settings as settings_router, # Phase 1 AUTH-03: HF token save/clear/state
|
||||
media_tools as media_tools_router, # Audio tools: ffmpeg/ffprobe/yt-dlp management
|
||||
)
|
||||
from utils import hf_progress
|
||||
|
||||
@@ -1045,6 +1058,7 @@ app.include_router(audiobook.router)
|
||||
app.include_router(longform_jobs.router)
|
||||
app.include_router(pronunciation.router) # Expressive-TTS Spec 01: pronunciation dictionary
|
||||
app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints
|
||||
app.include_router(media_tools_router.router) # Settings → Audio tools + wizard media-engine self-heal
|
||||
from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402
|
||||
app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings
|
||||
|
||||
|
||||
@@ -151,9 +151,14 @@ async def generate_segments_batched(
|
||||
# Raw: skip all DSP — return raw model output
|
||||
return audio_out
|
||||
|
||||
# TODO(#312): this route runs the OmniVoice model directly (not the active
|
||||
# backend), so VoxCPM2 never reaches it. When these routes become
|
||||
# engine-aware, guard with `if not getattr(backend, "applies_own_mastering", False)`.
|
||||
# NOTE(#312, closed): the live routes (generation.py,
|
||||
# dub_generate.py, batch.py, tts_stream.py) are engine-aware
|
||||
# and honor `applies_own_mastering` themselves. This module
|
||||
# still drives the OmniVoice model directly and currently has
|
||||
# NO call sites — it's an unintegrated throughput experiment.
|
||||
# If it is ever wired into a route, thread the active backend
|
||||
# through instead of `model` and guard the mastering below
|
||||
# with `if not getattr(backend, "applies_own_mastering", False)`.
|
||||
mastered = apply_mastering(audio_out, sample_rate=sr)
|
||||
effect_chain = get_effect_chain(seg_effect_preset)
|
||||
if effect_chain:
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"""Automatic Hugging Face endpoint selection — probe, pick, remember.
|
||||
|
||||
Restricted-network first-runs (e.g. China, where huggingface.co is blocked)
|
||||
used to dead-end until the user found the mirror setting. This service makes
|
||||
that class of failure self-healing: it *races* the official endpoint against
|
||||
the community mirror with real connectivity probes and remembers the winner,
|
||||
so model downloads work out of the box wherever at least one endpoint is
|
||||
reachable.
|
||||
|
||||
Principles (owner-set):
|
||||
|
||||
- **Probes are the truth.** The decision comes only from actual reachability
|
||||
and latency measurements against endpoints the app would legitimately
|
||||
download from. Device locale/timezone is used *only* to order which
|
||||
endpoint gets probed first — never to decide. No geo-IP lookups, no
|
||||
third-party calls, no telemetry.
|
||||
- **Explicit choices are never auto-switched.** A user with an endpoint
|
||||
configured anywhere (Settings → Models, ``HF_ENDPOINT`` env, the
|
||||
``hf_endpoint`` pref) is in manual mode; auto applies only where nothing
|
||||
was chosen. ``OMNIVOICE_HF_ENDPOINT_MODE=manual`` is a hard env opt-out.
|
||||
- **Sticky, canonical-first decisions.** With both endpoints reachable the
|
||||
official endpoint wins unless the mirror is *decisively* faster
|
||||
(``MIRROR_SPEEDUP_FACTOR``× on latency, confirmed by an optional small
|
||||
ranged-GET throughput sample) — so noise can't flap users onto a mirror.
|
||||
The decision is cached in prefs and re-raced only on: no cached decision
|
||||
(first run), a network-classified download failure, an explicit
|
||||
"Test again", or a decision older than ``DECISION_MAX_AGE_S``.
|
||||
- **Integrity is a non-issue.** huggingface_hub verifies every download by
|
||||
etag/sha regardless of endpoint, so a mirror cannot silently corrupt
|
||||
models.
|
||||
|
||||
Application is **per-call**: download paths (Model Store installs, the model
|
||||
cache auto-repair) pass the effective endpoint as an ``endpoint=`` kwarg. The
|
||||
auto decision is never written to ``HF_ENDPOINT``/user_env — doing so would
|
||||
make it indistinguishable from an explicit user choice.
|
||||
|
||||
Pure and mocked-transport-testable: ``race()`` takes injectable probers, and
|
||||
tests patch the module-level ``probe_endpoint`` / ``throughput_probe``
|
||||
(resolved at call time). Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
logger = logging.getLogger("omnivoice.endpoint_race")
|
||||
|
||||
CANONICAL_ENDPOINT = "https://huggingface.co"
|
||||
COMMUNITY_MIRROR = "https://hf-mirror.com"
|
||||
|
||||
# Hard env opt-out: any of these values disables auto selection entirely.
|
||||
MODE_ENV = "OMNIVOICE_HF_ENDPOINT_MODE"
|
||||
_OPT_OUT_VALUES = {"manual", "off", "0", "false", "no"}
|
||||
|
||||
# prefs keys (core.prefs conventions: env > prefs.json > default).
|
||||
_MODE_PREF = "hf_endpoint_mode" # "auto" | "manual"; absent → default
|
||||
_DECISION_PREF = "hf_endpoint_auto" # cached decision dict (see race())
|
||||
|
||||
DECISION_MAX_AGE_S = 7 * 24 * 3600.0 # re-race a decision older than 7 days
|
||||
PROBE_TIMEOUT_S = 3.0 # short: a probe is not a download
|
||||
MIRROR_SPEEDUP_FACTOR = 3.0 # mirror must be ≥3× faster to win
|
||||
|
||||
# Small, stable, long-lived public file for the optional ranged-GET
|
||||
# throughput tiebreak (mirrors proxy the same /resolve/ paths).
|
||||
_THROUGHPUT_SAMPLE_PATH = "/openai-community/gpt2/resolve/main/model.safetensors"
|
||||
_THROUGHPUT_SAMPLE_BYTES = 256 * 1024
|
||||
|
||||
# Serialises race+persist so concurrent callers can't double-race.
|
||||
_race_lock = threading.Lock()
|
||||
|
||||
# Repos this process already re-raced for after a download failure — the
|
||||
# failover may only happen ONCE per repo per process (same guard pattern as
|
||||
# model_manager._LINK_REPAIR_ATTEMPTED) so a network that stays broken can't
|
||||
# loop probe↔retry.
|
||||
_FAILOVER_ATTEMPTED: set[str] = set()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProbeResult:
|
||||
endpoint: str
|
||||
reachable: bool
|
||||
latency_ms: Optional[float] = None
|
||||
error: str = "" # "", "timeout", "dns", "tls", "refused", "unreachable"
|
||||
|
||||
|
||||
# ── Probes (the only network code in this module) ───────────────────────────
|
||||
|
||||
def _classify_probe_error(exc: Exception) -> str:
|
||||
"""Coarse failure class for a probe, for logs/UI — never raises."""
|
||||
import socket
|
||||
import ssl
|
||||
|
||||
if isinstance(exc, (socket.timeout, TimeoutError)):
|
||||
return "timeout"
|
||||
if isinstance(exc, ssl.SSLError):
|
||||
return "tls"
|
||||
reason = getattr(exc, "reason", None)
|
||||
if isinstance(reason, socket.gaierror):
|
||||
return "dns"
|
||||
if isinstance(reason, (socket.timeout, TimeoutError)):
|
||||
return "timeout"
|
||||
if isinstance(reason, ssl.SSLError):
|
||||
return "tls"
|
||||
if isinstance(exc, ConnectionRefusedError) or isinstance(reason, ConnectionRefusedError):
|
||||
return "refused"
|
||||
return "unreachable"
|
||||
|
||||
|
||||
def probe_endpoint(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> ProbeResult:
|
||||
"""HTTPS reachability + latency: one HEAD to the endpoint root.
|
||||
|
||||
Any HTTP response (even an error status) counts as reachable — the probe
|
||||
measures whether the network path works, not whether a specific resource
|
||||
exists. Never raises."""
|
||||
url = endpoint.rstrip("/") + "/"
|
||||
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "OmniVoice-endpoint-probe"})
|
||||
start = time.monotonic()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout):
|
||||
pass
|
||||
except urllib.error.HTTPError:
|
||||
pass # the server answered → reachable
|
||||
except Exception as exc:
|
||||
return ProbeResult(endpoint=endpoint, reachable=False, error=_classify_probe_error(exc))
|
||||
return ProbeResult(
|
||||
endpoint=endpoint,
|
||||
reachable=True,
|
||||
latency_ms=round((time.monotonic() - start) * 1000.0, 1),
|
||||
)
|
||||
|
||||
|
||||
def throughput_probe(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> Optional[float]:
|
||||
"""Bytes/second over a small ranged GET of a stable public file, or None.
|
||||
|
||||
Used only as a tiebreak confirmation when latency says the mirror is
|
||||
decisively faster — throughput is what a multi-GB download actually
|
||||
feels. Best-effort; any failure returns None (tiebreak skipped)."""
|
||||
url = endpoint.rstrip("/") + _THROUGHPUT_SAMPLE_PATH
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Range": f"bytes=0-{_THROUGHPUT_SAMPLE_BYTES - 1}",
|
||||
"User-Agent": "OmniVoice-endpoint-probe",
|
||||
},
|
||||
)
|
||||
deadline = time.monotonic() + timeout
|
||||
total = 0
|
||||
start = time.monotonic()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
while total < _THROUGHPUT_SAMPLE_BYTES and time.monotonic() < deadline:
|
||||
chunk = resp.read(min(65536, _THROUGHPUT_SAMPLE_BYTES - total))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
except Exception:
|
||||
return None
|
||||
elapsed = max(time.monotonic() - start, 1e-6)
|
||||
if total <= 0:
|
||||
return None
|
||||
return total / elapsed
|
||||
|
||||
|
||||
# ── Locale/timezone probe-ORDER hint (stdlib only, never a decision) ────────
|
||||
|
||||
def _hint_sources() -> tuple[list[str], list[str]]:
|
||||
"""(locale strings, timezone strings) from the environment — best-effort."""
|
||||
locs: list[str] = []
|
||||
for key in ("LC_ALL", "LC_MESSAGES", "LANG"):
|
||||
v = os.environ.get(key)
|
||||
if v:
|
||||
locs.append(v)
|
||||
try:
|
||||
import locale as _locale
|
||||
|
||||
locs.extend(x for x in _locale.getlocale() if x)
|
||||
except Exception:
|
||||
pass
|
||||
tzs: list[str] = []
|
||||
tz_env = os.environ.get("TZ")
|
||||
if tz_env:
|
||||
tzs.append(tz_env)
|
||||
try:
|
||||
tzs.extend(x for x in time.tzname if x)
|
||||
except Exception:
|
||||
pass
|
||||
return locs, tzs
|
||||
|
||||
|
||||
_CN_TZ_NAMES = {"asia/shanghai", "asia/chongqing", "asia/urumqi", "asia/harbin"}
|
||||
|
||||
|
||||
def cn_probe_hint(
|
||||
locale_strings: Optional[list[str]] = None,
|
||||
tz_strings: Optional[list[str]] = None,
|
||||
) -> bool:
|
||||
"""True when device language/region/timezone *suggests* mainland China.
|
||||
|
||||
Purely cosmetic: it reorders which endpoint gets probed first (so the
|
||||
likely winner's result lands soonest); every candidate is always probed
|
||||
and the decision comes from the probes alone (VPNs, expats, and corporate
|
||||
networks make region a lie). Never raises."""
|
||||
try:
|
||||
if locale_strings is None or tz_strings is None:
|
||||
env_locs, env_tzs = _hint_sources()
|
||||
locale_strings = env_locs if locale_strings is None else locale_strings
|
||||
tz_strings = env_tzs if tz_strings is None else tz_strings
|
||||
for raw in locale_strings:
|
||||
norm = raw.strip().lower().replace("-", "_")
|
||||
if "zh_cn" in norm or "zh_hans" in norm or "china" in norm:
|
||||
return True
|
||||
for raw in tz_strings:
|
||||
norm = raw.strip().lower()
|
||||
if norm in _CN_TZ_NAMES or "china standard time" in norm:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def candidates(cn_hint: Optional[bool] = None) -> list[str]:
|
||||
"""The endpoint registry, probe-ordered by the locale/timezone hint."""
|
||||
if cn_hint is None:
|
||||
cn_hint = cn_probe_hint()
|
||||
if cn_hint:
|
||||
return [COMMUNITY_MIRROR, CANONICAL_ENDPOINT]
|
||||
return [CANONICAL_ENDPOINT, COMMUNITY_MIRROR]
|
||||
|
||||
|
||||
# ── Mode / explicit-setting resolution ──────────────────────────────────────
|
||||
|
||||
def env_opt_out() -> bool:
|
||||
return (os.environ.get(MODE_ENV) or "").strip().lower() in _OPT_OUT_VALUES
|
||||
|
||||
|
||||
def explicit_endpoint() -> str:
|
||||
"""The endpoint the user explicitly configured, or "".
|
||||
|
||||
Same resolution the download paths use: ``HF_ENDPOINT`` env (what
|
||||
Settings → Models persists via user_env and what main.py loads at boot)
|
||||
with the ``hf_endpoint`` pref as fallback. Unlike
|
||||
``core.failure.configured_hf_mirror`` this does NOT filter the official
|
||||
endpoint — explicitly choosing huggingface.co is still an explicit
|
||||
choice. Never raises."""
|
||||
ep = (os.environ.get("HF_ENDPOINT") or "").strip().rstrip("/")
|
||||
if ep:
|
||||
return ep
|
||||
try:
|
||||
from core import prefs
|
||||
|
||||
return str(prefs.get("hf_endpoint", "") or "").strip().rstrip("/")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def mode() -> str:
|
||||
"""``"auto"`` or ``"manual"``. Manual whenever the user opted out via
|
||||
env, has an explicit endpoint anywhere, or picked a manual mode in
|
||||
Settings (including explicitly choosing the official endpoint)."""
|
||||
if env_opt_out():
|
||||
return "manual"
|
||||
if explicit_endpoint():
|
||||
return "manual"
|
||||
try:
|
||||
from core import prefs
|
||||
|
||||
if str(prefs.get(_MODE_PREF, "") or "").strip().lower() == "manual":
|
||||
return "manual"
|
||||
except Exception:
|
||||
pass
|
||||
return "auto"
|
||||
|
||||
|
||||
def set_mode_pref(value: str) -> None:
|
||||
"""Persist the Settings-panel mode choice ("auto" | "manual")."""
|
||||
from core import prefs
|
||||
|
||||
prefs.set_(_MODE_PREF, value)
|
||||
|
||||
|
||||
# ── Decision cache (prefs conventions) ──────────────────────────────────────
|
||||
|
||||
def cached_decision() -> Optional[dict]:
|
||||
"""The persisted race decision, or None. Shape-validated; never raises."""
|
||||
try:
|
||||
from core import prefs
|
||||
|
||||
d = prefs.get(_DECISION_PREF)
|
||||
except Exception:
|
||||
return None
|
||||
if (
|
||||
isinstance(d, dict)
|
||||
and isinstance(d.get("endpoint"), str)
|
||||
and d.get("endpoint")
|
||||
and isinstance(d.get("checked_at"), (int, float))
|
||||
):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def _store_decision(decision: dict) -> None:
|
||||
try:
|
||||
from core import prefs
|
||||
|
||||
prefs.set_(_DECISION_PREF, decision)
|
||||
except Exception: # a broken prefs file must never break downloads
|
||||
logger.warning("could not persist endpoint decision", exc_info=True)
|
||||
|
||||
|
||||
def decision_is_fresh(decision: Optional[dict], now: Optional[float] = None) -> bool:
|
||||
if not decision:
|
||||
return False
|
||||
now = time.time() if now is None else now
|
||||
age = now - float(decision.get("checked_at") or 0)
|
||||
return 0 <= age <= DECISION_MAX_AGE_S
|
||||
|
||||
|
||||
# ── The race ────────────────────────────────────────────────────────────────
|
||||
|
||||
def race(
|
||||
endpoints: Optional[list[str]] = None,
|
||||
prober: Optional[Callable[[str], ProbeResult]] = None,
|
||||
throughput_prober: Optional[Callable[[str], Optional[float]]] = None,
|
||||
now: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""Probe all candidates in parallel and decide. Pure given the probers.
|
||||
|
||||
Policy: reachable beats unreachable; with both reachable the canonical
|
||||
endpoint wins unless the mirror is ≥``MIRROR_SPEEDUP_FACTOR``× faster on
|
||||
latency AND the ranged-GET throughput sample doesn't contradict it (a
|
||||
failed/unavailable throughput probe leaves the latency verdict standing).
|
||||
Neither reachable → canonical, ``reachable=False`` (nothing works anyway;
|
||||
the offline copy owns messaging).
|
||||
|
||||
Returns ``{"endpoint", "reachable", "latency_ms", "checked_at",
|
||||
"results": [...]}``.
|
||||
"""
|
||||
cands = endpoints if endpoints is not None else candidates()
|
||||
# Resolve module attrs at call time so tests can patch probe_endpoint /
|
||||
# throughput_probe and every caller (preflight, Settings) picks it up.
|
||||
do_probe = prober if prober is not None else probe_endpoint
|
||||
do_throughput = throughput_prober if throughput_prober is not None else throughput_probe
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, len(cands))) as pool:
|
||||
results = list(pool.map(do_probe, cands))
|
||||
|
||||
by_endpoint = {r.endpoint: r for r in results}
|
||||
canonical = by_endpoint.get(CANONICAL_ENDPOINT)
|
||||
reachable = [r for r in results if r.reachable and r.latency_ms is not None]
|
||||
reachable.sort(key=lambda r: r.latency_ms)
|
||||
|
||||
if not reachable:
|
||||
winner = ProbeResult(endpoint=CANONICAL_ENDPOINT, reachable=False,
|
||||
error=(canonical.error if canonical else "unreachable"))
|
||||
elif canonical is None or not canonical.reachable:
|
||||
winner = reachable[0]
|
||||
else:
|
||||
winner = canonical
|
||||
fastest_mirror = next((r for r in reachable if r.endpoint != CANONICAL_ENDPOINT), None)
|
||||
if (
|
||||
fastest_mirror is not None
|
||||
and canonical.latency_ms is not None
|
||||
and fastest_mirror.latency_ms * MIRROR_SPEEDUP_FACTOR <= canonical.latency_ms
|
||||
):
|
||||
# Decisive latency win — confirm with throughput (what a real
|
||||
# multi-GB download feels) before moving the user off canonical.
|
||||
tp_mirror = do_throughput(fastest_mirror.endpoint)
|
||||
tp_canonical = do_throughput(CANONICAL_ENDPOINT)
|
||||
if tp_mirror is not None and tp_canonical is not None and tp_mirror < tp_canonical:
|
||||
winner = canonical # latency was noise; canonical still wins
|
||||
else:
|
||||
winner = fastest_mirror
|
||||
|
||||
decision = {
|
||||
"endpoint": winner.endpoint,
|
||||
"reachable": winner.reachable,
|
||||
"latency_ms": winner.latency_ms,
|
||||
"checked_at": time.time() if now is None else now,
|
||||
"results": [asdict(r) for r in results],
|
||||
}
|
||||
logger.info(
|
||||
"HF endpoint race: picked %s (reachable=%s, latency=%sms) from %s",
|
||||
winner.endpoint, winner.reachable, winner.latency_ms,
|
||||
[(r.endpoint, r.reachable, r.latency_ms) for r in results],
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
def ensure_decision(
|
||||
force: bool = False,
|
||||
prober: Optional[Callable[[str], ProbeResult]] = None,
|
||||
throughput_prober: Optional[Callable[[str], Optional[float]]] = None,
|
||||
) -> Optional[dict]:
|
||||
"""The current auto decision, racing only when needed. None in manual mode.
|
||||
|
||||
Races when: no cached decision (first run), the cache is stale
|
||||
(>``DECISION_MAX_AGE_S``), or ``force=True`` (preflight, "Test again",
|
||||
download-failure failover). Otherwise the cached decision is returned
|
||||
untouched — launches stay probe-free."""
|
||||
if mode() != "auto":
|
||||
return None
|
||||
with _race_lock:
|
||||
d = cached_decision()
|
||||
if not force and decision_is_fresh(d):
|
||||
return d
|
||||
d = race(prober=prober, throughput_prober=throughput_prober)
|
||||
_store_decision(d)
|
||||
return d
|
||||
|
||||
|
||||
def effective_endpoint() -> Optional[str]:
|
||||
"""The endpoint downloads should pass as ``endpoint=``, or None (canonical).
|
||||
|
||||
Explicit user configuration always wins; in auto mode this returns the
|
||||
cached decision's mirror when one was picked. NEVER probes — safe on the
|
||||
per-download hot path. Never raises."""
|
||||
try:
|
||||
ep = explicit_endpoint()
|
||||
if ep:
|
||||
return ep
|
||||
if mode() != "auto":
|
||||
return None
|
||||
d = cached_decision()
|
||||
if d and d.get("reachable") and d["endpoint"] != CANONICAL_ENDPOINT:
|
||||
return d["endpoint"]
|
||||
except Exception:
|
||||
logger.warning("effective_endpoint failed; using canonical", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def reselect_after_failure(repo_id: str, reason: Optional[str] = None) -> bool:
|
||||
"""After a network-classified download failure: re-race once and report
|
||||
whether the effective endpoint changed (the caller then retries on it).
|
||||
|
||||
Guarded once per repo per process (mirrors the cache-recovery ladder's
|
||||
retry-once guard) so a network that stays broken can't loop probe↔retry.
|
||||
No-op in manual mode and for non-network failures. Never raises."""
|
||||
try:
|
||||
if mode() != "auto":
|
||||
return False
|
||||
if reason is not None:
|
||||
from core.failure import is_hf_connectivity_error
|
||||
|
||||
if not is_hf_connectivity_error(reason):
|
||||
return False
|
||||
if repo_id in _FAILOVER_ATTEMPTED:
|
||||
return False
|
||||
_FAILOVER_ATTEMPTED.add(repo_id)
|
||||
before = effective_endpoint()
|
||||
ensure_decision(force=True)
|
||||
after = effective_endpoint()
|
||||
if after != before:
|
||||
logger.warning(
|
||||
"HF endpoint failover for %s: %s → %s (download failed with a "
|
||||
"network error; retrying on the new endpoint)",
|
||||
repo_id, before or CANONICAL_ENDPOINT, after or CANONICAL_ENDPOINT,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("endpoint failover for %s errored", repo_id, exc_info=True)
|
||||
return False
|
||||
@@ -57,9 +57,13 @@ def find_ffmpeg():
|
||||
"""Locate an ffmpeg binary.
|
||||
|
||||
Resolution order:
|
||||
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled).
|
||||
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled, or
|
||||
by the user's Settings → Audio tools override via prefs).
|
||||
2. ``imageio-ffmpeg`` pip package (ships a static binary per platform).
|
||||
3. Common system paths / ``PATH``.
|
||||
3. OmniVoice-acquired static bundle (``services.media_tools``) — the
|
||||
checksummed build the app downloads itself when nothing else
|
||||
resolves; the only bundled tier that also ships ffprobe.
|
||||
4. Common system paths / ``PATH``.
|
||||
|
||||
Returns the path string, or ``None`` if nothing found.
|
||||
"""
|
||||
@@ -78,7 +82,13 @@ def find_ffmpeg():
|
||||
logger.debug("imageio_ffmpeg binary not usable at %s", candidate)
|
||||
except Exception as e:
|
||||
logger.debug("imageio_ffmpeg unavailable: %s", e)
|
||||
# 3. Well-known system paths + PATH lookup
|
||||
# 3. OmniVoice-acquired bundled static binary (never downloads here —
|
||||
# acquisition is media_tools' background job; this only picks up an
|
||||
# already-installed build).
|
||||
candidate = _acquired_bundled("ffmpeg")
|
||||
if candidate:
|
||||
return candidate
|
||||
# 4. Well-known system paths + PATH lookup
|
||||
common = [
|
||||
"/opt/homebrew/bin/ffmpeg",
|
||||
"/usr/local/bin/ffmpeg",
|
||||
@@ -95,6 +105,22 @@ def find_ffmpeg():
|
||||
return None
|
||||
|
||||
|
||||
def _acquired_bundled(tool: str) -> "str | None":
|
||||
"""Already-acquired media_tools static binary, validated — or None.
|
||||
|
||||
Lazy import: media_tools imports from this module at its top, so this
|
||||
module must only reach back at call time (no cycle).
|
||||
"""
|
||||
try:
|
||||
from services.media_tools import bundled_tool_path
|
||||
candidate = bundled_tool_path(tool)
|
||||
if candidate and _binary_runs(candidate):
|
||||
return candidate
|
||||
except Exception as e:
|
||||
logger.debug("media_tools bundled %s unavailable: %s", tool, e)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_ffprobe() -> str | None:
|
||||
"""Resolve an ffprobe binary path.
|
||||
|
||||
@@ -103,8 +129,12 @@ def resolve_ffprobe() -> str | None:
|
||||
injected by Tauri pointing at the bundled sidecar (e.g.
|
||||
``/usr/lib/omnivoice-studio/bin/ffprobe`` on .deb installs).
|
||||
2. ``FFPROBE_PATH`` env var — legacy alias kept for backward
|
||||
compatibility with older Tauri shells / dev environments.
|
||||
3. ``shutil.which("ffprobe")`` — system ``PATH`` fallback.
|
||||
compatibility with older Tauri shells / dev environments; also the
|
||||
key Settings → Audio tools persists a user override under.
|
||||
3. OmniVoice-acquired static bundle (``services.media_tools``) —
|
||||
imageio-ffmpeg ships no ffprobe, so this is the bundled tier that
|
||||
closes the source-install gap.
|
||||
4. ``shutil.which("ffprobe")`` — system ``PATH`` fallback.
|
||||
|
||||
Returns the resolved path string, or ``None`` if nothing found. Callers
|
||||
that need a hard failure should use :func:`find_ffprobe` instead.
|
||||
@@ -121,6 +151,10 @@ def resolve_ffprobe() -> str | None:
|
||||
if resolved and _binary_runs(resolved):
|
||||
return resolved
|
||||
|
||||
bundled = _acquired_bundled("ffprobe")
|
||||
if bundled:
|
||||
return bundled
|
||||
|
||||
system_probe = shutil.which("ffprobe")
|
||||
if system_probe and _binary_runs(system_probe):
|
||||
return system_probe
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Media tools — ffmpeg / ffprobe / yt-dlp as an invisible internal concern.
|
||||
|
||||
Most users should never learn what ffmpeg is. This module makes the media
|
||||
engine self-contained: it reports where each tool comes from, acquires a
|
||||
bundled static build in the background when no tier of the resolution chain
|
||||
(``services.ffmpeg_utils``) resolves, and gives power users explicit
|
||||
control (custom path / system copy / restore bundled) through the
|
||||
``/media-tools`` router — persisted via the same ``env.FFMPEG_PATH`` /
|
||||
``env.FFPROBE_PATH`` prefs convention the Settings env writer already uses,
|
||||
so there is exactly one override mechanism.
|
||||
|
||||
Bundled-binary source (decision record)
|
||||
---------------------------------------
|
||||
The gap: ``imageio-ffmpeg`` (already a locked dep) ships a static *ffmpeg*
|
||||
inside its platform wheels but **no ffprobe**, so source installs without a
|
||||
system ffmpeg lose ``/tools/probe``, Smart-Fit duration checks, and VFR
|
||||
detection. Two options were audited:
|
||||
|
||||
(a) the ``static-ffmpeg`` pip package — ships BOTH binaries per platform via
|
||||
lazy download. **Rejected**: it downloads from a *mutable* URL
|
||||
(``.../ffmpeg_bins/raw/main/...`` — the branch tip, not a pinned
|
||||
release), performs **no checksum validation**, extracts into its own
|
||||
``site-packages`` directory (read-only / non-existent in the frozen
|
||||
PyInstaller backend), and drags in ``requests``/``filelock``/``progress``
|
||||
plus a stdout spinner.
|
||||
|
||||
(b) fetch the same upstream static builds ourselves, pinned to an immutable
|
||||
commit. **Chosen**: we download the platform zip from
|
||||
``github.com/zackees/ffmpeg_bins`` at a pinned commit SHA (immutable
|
||||
URL), verify size + SHA-256 against constants recorded from that
|
||||
commit's git-LFS pointers, extract only ffmpeg/ffprobe into a
|
||||
user-writable, update-surviving dir under ``DATA_DIR``, and trust a
|
||||
binary only after the existing ``_binary_runs`` ``-version`` probe.
|
||||
Stdlib-only (urllib honors HTTP(S)_PROXY), identical behavior on
|
||||
macOS (arm64 + x86_64), Windows x64, and Linux (x64 + arm64), and zero
|
||||
new Python dependencies.
|
||||
|
||||
yt-dlp updates (decision record)
|
||||
--------------------------------
|
||||
yt-dlp is an importable locked dep — never a user-installed requirement.
|
||||
But site support rots faster than app releases, so Settings offers a
|
||||
user-triggered "Update". A plain in-venv upgrade was audited and rejected:
|
||||
the app venv is uv-managed (no pip module), and the updater's drift sync
|
||||
(#1029/#1030, ``uv sync --frozen --inexact``) preserves only packages *not*
|
||||
in the lockfile — yt-dlp IS locked, so an in-venv upgrade would be silently
|
||||
reverted on the next app update, and the frozen build has no installer at
|
||||
all. Instead we install the new wheel (pure-python, no required deps —
|
||||
matching the plain ``yt-dlp`` spec pinned in pyproject) into an **overlay
|
||||
directory** under ``DATA_DIR`` — SHA-256-verified against PyPI's own
|
||||
metadata — and prepend it to ``sys.path`` at startup. It survives app
|
||||
updates and drift syncs, works identically in source and frozen builds, and
|
||||
"Restore tested version" is simply deleting the overlay: the locked wheel
|
||||
underneath was never touched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import zipfile
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core import prefs
|
||||
from services.ffmpeg_utils import _binary_runs, _BINARY_OK
|
||||
|
||||
logger = logging.getLogger("omnivoice.media_tools")
|
||||
|
||||
# ── Pinned bundled build ────────────────────────────────────────────────────
|
||||
# Immutable commit of github.com/zackees/ffmpeg_bins (the upstream the
|
||||
# static-ffmpeg pip package also consumes, but pinned + checksummed here).
|
||||
# SHA-256 values are the git-LFS oids of the v8.0 platform zips at this
|
||||
# commit, independently verified by downloading and hashing.
|
||||
_FFBIN_REPO = "zackees/ffmpeg_bins"
|
||||
_FFBIN_COMMIT = "df95abcb0ce6efff710dda5ef28a2f6f1dc21493" # 2026-01-16
|
||||
_FFBIN_TREE = "v8.0"
|
||||
|
||||
#: platform key → (sha256, size in bytes) of the zip at the pinned commit.
|
||||
_FFBIN_SHA256 = {
|
||||
"darwin": ("70fd5b21cb37b6ea97c8b584cf76b3cc6a90179831c9c269811b9716c28605fb", 53079896),
|
||||
"darwin_arm64": ("b2da44a8169c4d09a97db996250690c3346f72e4795521d23d3dbb1e72421207", 41925556),
|
||||
"linux": ("ca75b05e887c7a97676632f673031875847be83daa9794298fed9cef8cac14ad", 142008975),
|
||||
"linux_arm64": ("e03efe471c03b999f10988d5db62ae3bd94837463291b3c7755528b100e97d6f", 131816005),
|
||||
"win32": ("92662c2241e93fe71b3f3a01e94a0b0dc8cfad726019f96b83bc109ce44c5d0b", 72065209),
|
||||
}
|
||||
|
||||
_PYPI_YTDLP_URL = "https://pypi.org/pypi/yt-dlp/json"
|
||||
|
||||
_DOWNLOAD_TIMEOUT_S = 30 # per-read socket timeout; downloads stream in chunks
|
||||
_CHUNK = 256 * 1024
|
||||
|
||||
#: tool → env keys honored by the resolution chain, in precedence order.
|
||||
_ENV_KEYS = {
|
||||
"ffmpeg": ("FFMPEG_PATH",),
|
||||
"ffprobe": ("OMNIVOICE_FFPROBE_PATH", "FFPROBE_PATH"),
|
||||
}
|
||||
#: tool → the env key the *user override* is persisted under (prefs `env.<KEY>`).
|
||||
_PREF_ENV_KEY = {"ffmpeg": "FFMPEG_PATH", "ffprobe": "FFPROBE_PATH"}
|
||||
|
||||
TOOLS = ("ffmpeg", "ffprobe")
|
||||
|
||||
# ── Background-operation state (poll via status()) ─────────────────────────
|
||||
|
||||
_lock = threading.Lock()
|
||||
_ops: dict[str, dict] = {
|
||||
"acquire": {"state": "idle", "progress": 0.0, "error": None},
|
||||
"ytdlp_update": {"state": "idle", "progress": 0.0, "error": None, "version": None},
|
||||
}
|
||||
_version_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def _set_op(op: str, **fields) -> None:
|
||||
with _lock:
|
||||
_ops[op].update(fields)
|
||||
|
||||
|
||||
def _op_snapshot() -> dict:
|
||||
with _lock:
|
||||
return {k: dict(v) for k, v in _ops.items()}
|
||||
|
||||
|
||||
# ── Platform / paths ────────────────────────────────────────────────────────
|
||||
|
||||
def _platform_key() -> str:
|
||||
import platform as _p
|
||||
is_arm = _p.machine().lower() in ("arm64", "aarch64")
|
||||
if sys.platform == "win32":
|
||||
return "win32"
|
||||
if sys.platform == "darwin":
|
||||
return "darwin_arm64" if is_arm else "darwin"
|
||||
if sys.platform.startswith("linux"):
|
||||
return "linux_arm64" if is_arm else "linux"
|
||||
return sys.platform
|
||||
|
||||
|
||||
def media_tools_dir() -> str:
|
||||
"""User-writable root for acquired binaries + the yt-dlp overlay.
|
||||
|
||||
Lives in DATA_DIR so it survives app updates (the app bundle / venv are
|
||||
replaced wholesale on update; DATA_DIR is user state) and is writable in
|
||||
frozen installs.
|
||||
"""
|
||||
return os.path.join(DATA_DIR, "media_tools")
|
||||
|
||||
|
||||
def bundled_dir() -> str:
|
||||
# Versioned by the pin so a future pin bump lands in a fresh dir and
|
||||
# "Update" is a plain re-acquire — no in-place mutation of a live binary.
|
||||
return os.path.join(media_tools_dir(), f"ffbin-{_FFBIN_COMMIT[:12]}", _platform_key())
|
||||
|
||||
|
||||
def _exe(name: str) -> str:
|
||||
return f"{name}.exe" if sys.platform == "win32" else name
|
||||
|
||||
|
||||
def bundled_tool_path(tool: str) -> str | None:
|
||||
"""Path of an already-acquired bundled binary, or None. Never downloads."""
|
||||
p = os.path.join(bundled_dir(), _exe(tool))
|
||||
return p if os.path.isfile(p) else None
|
||||
|
||||
|
||||
def _bundle_url() -> str:
|
||||
# github.com/<repo>/raw/<commit> redirects to the LFS media host and
|
||||
# serves the real zip (raw.githubusercontent.com would return the
|
||||
# 133-byte LFS pointer instead).
|
||||
return f"https://github.com/{_FFBIN_REPO}/raw/{_FFBIN_COMMIT}/{_FFBIN_TREE}/{_platform_key()}.zip"
|
||||
|
||||
|
||||
def _expected_bundle() -> tuple[str, str, int]:
|
||||
"""(url, sha256, size) for this platform. Raises on unsupported platform."""
|
||||
key = _platform_key()
|
||||
if key not in _FFBIN_SHA256:
|
||||
raise RuntimeError(f"no bundled media-engine build for platform '{key}'")
|
||||
sha, size = _FFBIN_SHA256[key]
|
||||
return _bundle_url(), sha, size
|
||||
|
||||
|
||||
# ── Download helper ─────────────────────────────────────────────────────────
|
||||
|
||||
def _download(url: str, dest_path: str, expected_sha256: str,
|
||||
expected_size: int | None, op: str) -> None:
|
||||
"""Stream *url* to *dest_path*, hashing on the fly; raise on mismatch.
|
||||
|
||||
Progress is reported into ``_ops[op]["progress"]``. urllib honors the
|
||||
HTTP(S)_PROXY env vars, so restricted-network users' proxy settings apply.
|
||||
"""
|
||||
import urllib.request
|
||||
|
||||
if not url.startswith("https://"):
|
||||
raise ValueError("media-tools downloads must be https")
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "OmniVoice-Studio"})
|
||||
hasher = hashlib.sha256()
|
||||
done = 0
|
||||
with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
|
||||
total = expected_size or int(resp.headers.get("Content-Length") or 0)
|
||||
with open(dest_path, "wb") as f:
|
||||
while True:
|
||||
chunk = resp.read(_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
hasher.update(chunk)
|
||||
done += len(chunk)
|
||||
if total:
|
||||
_set_op(op, progress=min(done / total, 1.0))
|
||||
digest = hasher.hexdigest()
|
||||
if expected_size is not None and done != expected_size:
|
||||
raise RuntimeError(f"download size mismatch: got {done}, expected {expected_size}")
|
||||
if digest != expected_sha256:
|
||||
raise RuntimeError("download checksum mismatch — refusing to install")
|
||||
|
||||
|
||||
# ── Bundled acquisition ─────────────────────────────────────────────────────
|
||||
|
||||
def acquire_bundled(wait: bool = False) -> dict:
|
||||
"""Fetch + verify + install the pinned static ffmpeg/ffprobe build.
|
||||
|
||||
Idempotent: a no-op when the binaries are already present and runnable,
|
||||
or when an acquisition is already running. Runs in a daemon thread so it
|
||||
never blocks the caller (``wait=True`` is for tests/CLI use).
|
||||
Returns the op-state snapshot.
|
||||
"""
|
||||
with _lock:
|
||||
if _ops["acquire"]["state"] == "running":
|
||||
return dict(_ops["acquire"])
|
||||
_ops["acquire"].update(state="running", progress=0.0, error=None)
|
||||
|
||||
if all(bundled_tool_path(t) and _binary_runs(bundled_tool_path(t)) for t in TOOLS):
|
||||
_set_op("acquire", state="done", progress=1.0)
|
||||
return _op_snapshot()["acquire"]
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
_do_acquire()
|
||||
_set_op("acquire", state="done", progress=1.0, error=None)
|
||||
logger.info("media-tools: bundled ffmpeg/ffprobe installed at %s", bundled_dir())
|
||||
except Exception as e:
|
||||
logger.warning("media-tools: bundled acquisition failed: %s", e)
|
||||
_set_op("acquire", state="error", error=str(e)[:300])
|
||||
|
||||
if wait:
|
||||
_worker()
|
||||
else:
|
||||
threading.Thread(target=_worker, name="media-tools-acquire", daemon=True).start()
|
||||
return _op_snapshot()["acquire"]
|
||||
|
||||
|
||||
def _do_acquire() -> None:
|
||||
url, sha, size = _expected_bundle()
|
||||
target = bundled_dir()
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=os.path.dirname(target)) as tmp:
|
||||
zip_path = os.path.join(tmp, "bundle.zip")
|
||||
_download(url, zip_path, sha, size, op="acquire")
|
||||
|
||||
# Extract only the two binaries, flattened by basename — layout-agnostic
|
||||
# and immune to zip-slip (we never honor archive paths).
|
||||
wanted = {_exe(t): t for t in TOOLS}
|
||||
staged = os.path.join(tmp, "staged")
|
||||
os.makedirs(staged, exist_ok=True)
|
||||
found: dict[str, str] = {}
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for member in zf.infolist():
|
||||
base = os.path.basename(member.filename)
|
||||
if base in wanted and not member.is_dir():
|
||||
out = os.path.join(staged, base)
|
||||
with zf.open(member) as src, open(out, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
# Owner-only rwx — the backend process is the sole consumer
|
||||
# of these binaries (least privilege; py/overly-permissive-file).
|
||||
os.chmod(out, 0o700)
|
||||
found[base] = out
|
||||
missing = set(wanted) - set(found)
|
||||
if missing:
|
||||
raise RuntimeError(f"bundle is missing {sorted(missing)}")
|
||||
|
||||
# Probe BEFORE trusting — a corrupt / wrong-arch binary must never
|
||||
# be installed (same contract as ffmpeg_utils._binary_runs at
|
||||
# resolution time, applied at install time).
|
||||
for base, path in found.items():
|
||||
_BINARY_OK.pop(path, None)
|
||||
if not _binary_runs(path):
|
||||
raise RuntimeError(f"downloaded {base} failed its -version probe")
|
||||
|
||||
# Finalize: swap the staged dir into place.
|
||||
if os.path.isdir(target):
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
os.replace(staged, target)
|
||||
|
||||
# Resolution caches may hold negative verdicts for the old paths.
|
||||
for t in TOOLS:
|
||||
p = os.path.join(target, _exe(t))
|
||||
_BINARY_OK.pop(p, None)
|
||||
_version_cache.pop(p, None)
|
||||
|
||||
|
||||
# ── Status / origin classification ─────────────────────────────────────────
|
||||
|
||||
def _tool_version(path: str) -> str | None:
|
||||
cached = _version_cache.get(path)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[path, "-version"], capture_output=True, text=True, timeout=10, check=False,
|
||||
).stdout
|
||||
m = re.match(r"^(?:ffmpeg|ffprobe) version (\S+)", out or "")
|
||||
if m:
|
||||
_version_cache[path] = m.group(1)
|
||||
return m.group(1)
|
||||
except Exception as e:
|
||||
logger.debug("version probe failed for %s: %s", os.path.basename(path), e)
|
||||
return None
|
||||
|
||||
|
||||
def _imageio_pkg_dir() -> str | None:
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
return os.path.dirname(os.path.abspath(imageio_ffmpeg.__file__))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _classify_origin(tool: str, path: str) -> str:
|
||||
"""sidecar | bundled | system | custom — where the resolved binary lives."""
|
||||
rp = os.path.realpath(path)
|
||||
for root in filter(None, (media_tools_dir(), _imageio_pkg_dir())):
|
||||
if rp.startswith(os.path.realpath(root) + os.sep):
|
||||
return "bundled"
|
||||
for key in _ENV_KEYS[tool]:
|
||||
v = os.environ.get(key)
|
||||
if not v:
|
||||
continue
|
||||
if v == path or os.path.realpath(v) == rp or shutil.which(v) == path:
|
||||
# The same env var serves two masters: the Tauri sidecar injects
|
||||
# it at spawn; a user override persists it via prefs `env.<KEY>`.
|
||||
return "custom" if prefs.get(f"env.{key}") else "sidecar"
|
||||
return "system"
|
||||
|
||||
|
||||
def _resolve(tool: str) -> str | None:
|
||||
from services import ffmpeg_utils
|
||||
if tool == "ffmpeg":
|
||||
return ffmpeg_utils.find_ffmpeg()
|
||||
return ffmpeg_utils.find_ffprobe()
|
||||
|
||||
|
||||
def _ytdlp_status() -> dict:
|
||||
"""yt-dlp is a python module, not a binary — status reads its version
|
||||
without paying the full package import."""
|
||||
info: dict = {"tool": "yt-dlp", "ok": False, "path": None, "version": None,
|
||||
"origin": "bundled", "overlay_version": None,
|
||||
"baseline_version": prefs.get("media_tools.ytdlp_baseline")}
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec("yt_dlp")
|
||||
origin = getattr(spec, "origin", None)
|
||||
if origin:
|
||||
pkg_dir = os.path.dirname(origin)
|
||||
info["path"] = pkg_dir
|
||||
info["ok"] = True
|
||||
info["version"] = _read_ytdlp_version(pkg_dir)
|
||||
if os.path.realpath(pkg_dir).startswith(
|
||||
os.path.realpath(_ytdlp_overlay_dir()) + os.sep):
|
||||
info["origin"] = "custom"
|
||||
except Exception as e:
|
||||
logger.debug("yt_dlp spec lookup failed: %s", e)
|
||||
ov = _read_ytdlp_version(os.path.join(_ytdlp_overlay_dir(), "yt_dlp"))
|
||||
info["overlay_version"] = ov
|
||||
return info
|
||||
|
||||
|
||||
def _read_ytdlp_version(pkg_dir: str) -> str | None:
|
||||
try:
|
||||
with open(os.path.join(pkg_dir, "version.py"), encoding="utf-8") as f:
|
||||
m = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", f.read())
|
||||
return m.group(1) if m else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""Full media-tools report: per-tool {ok, path, version, origin} + op states."""
|
||||
tools = {}
|
||||
for tool in TOOLS:
|
||||
path = _resolve(tool)
|
||||
tools[tool] = {
|
||||
"tool": tool,
|
||||
"ok": bool(path),
|
||||
"path": path,
|
||||
"version": _tool_version(path) if path else None,
|
||||
"origin": _classify_origin(tool, path) if path else None,
|
||||
}
|
||||
tools["ytdlp"] = _ytdlp_status()
|
||||
ops = _op_snapshot()
|
||||
return {
|
||||
"ready": tools["ffmpeg"]["ok"] and tools["ffprobe"]["ok"],
|
||||
"tools": tools,
|
||||
"ops": ops,
|
||||
"platform_key": _platform_key(),
|
||||
}
|
||||
|
||||
|
||||
def summary(auto_acquire: bool = False) -> dict:
|
||||
"""Small preflight-embeddable verdict. With ``auto_acquire``, kicks off
|
||||
the bundled download in the background when nothing resolves (first-run
|
||||
self-heal) — but never re-fires after a failed attempt (the wizard's
|
||||
failure card owns the Retry)."""
|
||||
st = status()
|
||||
op = st["ops"]["acquire"]
|
||||
if auto_acquire and not st["ready"] and op["state"] == "idle":
|
||||
op = acquire_bundled()
|
||||
return {
|
||||
"ready": st["ready"],
|
||||
"acquire": {"state": op["state"], "progress": op["progress"], "error": op["error"]},
|
||||
}
|
||||
|
||||
|
||||
# ── User overrides (persisted via the existing env-prefs convention) ───────
|
||||
|
||||
def _validate_binary_path(path: str) -> None:
|
||||
# Same defense-in-depth as /system/set-env: no control chars, must be an
|
||||
# existing file, and must actually run before we trust it.
|
||||
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in path):
|
||||
raise ValueError("Invalid path: control characters are not allowed")
|
||||
if not os.path.isfile(path):
|
||||
raise ValueError(f"File not found: {path}")
|
||||
_BINARY_OK.pop(path, None)
|
||||
if not _binary_runs(path):
|
||||
raise ValueError(
|
||||
"That file exists but does not run as a media tool "
|
||||
"(its `-version` probe failed) — wrong architecture or not executable."
|
||||
)
|
||||
|
||||
|
||||
def set_custom_path(tool: str, path: str) -> dict:
|
||||
"""Pin *tool* to an explicit binary. Persists via prefs `env.<KEY>` —
|
||||
the exact mechanism /system/set-env uses, so there is one override store."""
|
||||
if tool not in TOOLS:
|
||||
raise ValueError(f"unknown tool '{tool}'")
|
||||
path = path.strip()
|
||||
_validate_binary_path(path)
|
||||
key = _PREF_ENV_KEY[tool]
|
||||
os.environ[key] = path
|
||||
prefs.set_(f"env.{key}", path)
|
||||
_version_cache.pop(path, None)
|
||||
logger.info("media-tools: %s pinned to user path (origin=%s)",
|
||||
tool, _classify_origin(tool, path))
|
||||
return status()["tools"][tool]
|
||||
|
||||
|
||||
def use_system(tool: str) -> dict:
|
||||
"""Auto-detect a system-installed copy and pin it."""
|
||||
if tool not in TOOLS:
|
||||
raise ValueError(f"unknown tool '{tool}'")
|
||||
candidate = _detect_system(tool)
|
||||
if not candidate:
|
||||
raise LookupError(
|
||||
f"No system {tool} found on PATH or in the usual install locations."
|
||||
)
|
||||
return set_custom_path(tool, candidate)
|
||||
|
||||
|
||||
def _detect_system(tool: str) -> str | None:
|
||||
roots = [r for r in (media_tools_dir(), _imageio_pkg_dir()) if r]
|
||||
|
||||
def _is_bundled(p: str) -> bool:
|
||||
rp = os.path.realpath(p)
|
||||
return any(rp.startswith(os.path.realpath(r) + os.sep) for r in roots)
|
||||
|
||||
candidates = [
|
||||
f"/opt/homebrew/bin/{tool}",
|
||||
f"/usr/local/bin/{tool}",
|
||||
f"/usr/bin/{tool}",
|
||||
f"C:\\ffmpeg\\bin\\{tool}.exe",
|
||||
f"C:\\Program Files\\ffmpeg\\bin\\{tool}.exe",
|
||||
tool,
|
||||
]
|
||||
for c in candidates:
|
||||
resolved = shutil.which(c)
|
||||
if resolved and not _is_bundled(resolved) and _binary_runs(resolved):
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def restore_bundled(tool: str) -> dict:
|
||||
"""Clear the user override so the chain resolves sidecar → bundled →
|
||||
system again; kick acquisition if no bundled build is present. Always safe."""
|
||||
if tool not in TOOLS:
|
||||
raise ValueError(f"unknown tool '{tool}'")
|
||||
for key in _ENV_KEYS[tool]:
|
||||
if prefs.get(f"env.{key}"):
|
||||
prefs.delete(f"env.{key}")
|
||||
os.environ.pop(key, None)
|
||||
_version_cache.clear()
|
||||
if not (bundled_tool_path(tool) and _binary_runs(bundled_tool_path(tool))):
|
||||
# No local bundled build to fall back to (imageio may still cover
|
||||
# ffmpeg) — fetch ours in the background so the revert lands somewhere.
|
||||
if not _resolve(tool):
|
||||
acquire_bundled()
|
||||
return status()["tools"][tool]
|
||||
|
||||
|
||||
# ── yt-dlp overlay ──────────────────────────────────────────────────────────
|
||||
|
||||
def _ytdlp_overlay_dir() -> str:
|
||||
return os.path.join(media_tools_dir(), "ytdlp_overlay")
|
||||
|
||||
|
||||
def activate_ytdlp_overlay() -> bool:
|
||||
"""Prepend the user-updated yt-dlp overlay to sys.path. Called once at
|
||||
backend startup, before anything imports yt_dlp."""
|
||||
overlay = _ytdlp_overlay_dir()
|
||||
if os.path.isdir(os.path.join(overlay, "yt_dlp")) and overlay not in sys.path:
|
||||
sys.path.insert(0, overlay)
|
||||
logger.info("media-tools: yt-dlp overlay active (%s)",
|
||||
_read_ytdlp_version(os.path.join(overlay, "yt_dlp")) or "?")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_pypi_ytdlp() -> tuple[str, str, str]:
|
||||
"""(version, wheel_url, sha256) of the latest yt-dlp wheel on PyPI."""
|
||||
import json
|
||||
import urllib.request
|
||||
req = urllib.request.Request(_PYPI_YTDLP_URL, headers={"User-Agent": "OmniVoice-Studio"})
|
||||
with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
|
||||
meta = json.load(resp)
|
||||
version = meta["info"]["version"]
|
||||
for artifact in meta.get("urls", []):
|
||||
if artifact.get("packagetype") == "bdist_wheel" and \
|
||||
artifact["filename"].endswith("py3-none-any.whl"):
|
||||
return version, artifact["url"], artifact["digests"]["sha256"]
|
||||
raise RuntimeError(f"no universal wheel found for yt-dlp {version}")
|
||||
|
||||
|
||||
def update_ytdlp(wait: bool = False) -> dict:
|
||||
"""Install the newest yt-dlp into the overlay dir (background thread).
|
||||
|
||||
The wheel is verified against PyPI's own sha256 digest before a single
|
||||
byte lands in the overlay; the swap is atomic (staged dir + os.replace).
|
||||
Takes effect on the next backend start (the running process already
|
||||
imported the old module) — the UI shows the restart affordance.
|
||||
"""
|
||||
with _lock:
|
||||
if _ops["ytdlp_update"]["state"] == "running":
|
||||
return dict(_ops["ytdlp_update"])
|
||||
_ops["ytdlp_update"].update(state="running", progress=0.0, error=None, version=None)
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
version = _do_update_ytdlp()
|
||||
_set_op("ytdlp_update", state="done", progress=1.0, version=version)
|
||||
logger.info("media-tools: yt-dlp overlay updated to %s", version)
|
||||
except Exception as e:
|
||||
logger.warning("media-tools: yt-dlp update failed: %s", e)
|
||||
_set_op("ytdlp_update", state="error", error=str(e)[:300])
|
||||
|
||||
if wait:
|
||||
_worker()
|
||||
else:
|
||||
threading.Thread(target=_worker, name="media-tools-ytdlp", daemon=True).start()
|
||||
return _op_snapshot()["ytdlp_update"]
|
||||
|
||||
|
||||
def _do_update_ytdlp() -> str:
|
||||
version, url, sha = _fetch_pypi_ytdlp()
|
||||
|
||||
# Record the locked ("tested") version once, before the first overlay
|
||||
# ever activates — that's what "Restore tested version" reverts to.
|
||||
if prefs.get("media_tools.ytdlp_baseline") is None:
|
||||
current = _ytdlp_status()
|
||||
if current["origin"] == "bundled" and current["version"]:
|
||||
prefs.set_("media_tools.ytdlp_baseline", current["version"])
|
||||
|
||||
overlay = _ytdlp_overlay_dir()
|
||||
os.makedirs(media_tools_dir(), exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=media_tools_dir()) as tmp:
|
||||
whl = os.path.join(tmp, "yt_dlp.whl")
|
||||
_download(url, whl, sha, None, op="ytdlp_update")
|
||||
staged = os.path.join(tmp, "staged")
|
||||
with zipfile.ZipFile(whl) as zf:
|
||||
for member in zf.infolist():
|
||||
name = member.filename
|
||||
# Only the package itself; wheels carry no absolute paths but
|
||||
# guard against traversal anyway.
|
||||
if not name.startswith("yt_dlp/") or ".." in name:
|
||||
continue
|
||||
zf.extract(member, staged)
|
||||
got = _read_ytdlp_version(os.path.join(staged, "yt_dlp"))
|
||||
if not got:
|
||||
raise RuntimeError("downloaded wheel has no readable yt_dlp version")
|
||||
if os.path.isdir(overlay):
|
||||
shutil.rmtree(overlay, ignore_errors=True)
|
||||
os.replace(staged, overlay)
|
||||
return version
|
||||
|
||||
|
||||
def ytdlp_invocation() -> "tuple[list[str], dict[str, str] | None]":
|
||||
"""(argv prefix, env-or-None) for running the yt-dlp CLI.
|
||||
|
||||
Prefers ``[sys.executable, -m, yt_dlp]`` so the CLI always matches the
|
||||
module the app ships (or the user's overlay — propagated via PYTHONPATH),
|
||||
with no PATH requirement: yt-dlp is never something the user installs.
|
||||
Frozen builds can't re-invoke an interpreter, so they keep the historical
|
||||
PATH lookup as a last resort.
|
||||
"""
|
||||
if not getattr(sys, "frozen", False):
|
||||
try:
|
||||
import importlib.util
|
||||
if importlib.util.find_spec("yt_dlp") is not None:
|
||||
env = None
|
||||
overlay = _ytdlp_overlay_dir()
|
||||
if os.path.isdir(os.path.join(overlay, "yt_dlp")):
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = overlay + os.pathsep + env.get("PYTHONPATH", "")
|
||||
return [sys.executable, "-m", "yt_dlp"], env
|
||||
except Exception as e:
|
||||
logger.debug("yt_dlp module CLI unavailable: %s", e)
|
||||
exe = shutil.which("yt-dlp")
|
||||
return ([exe] if exe else ["yt-dlp"]), None
|
||||
|
||||
|
||||
def restore_ytdlp() -> dict:
|
||||
"""Delete the overlay — the locked, tested yt-dlp underneath takes over on
|
||||
next start. Always safe: the locked install was never modified."""
|
||||
overlay = _ytdlp_overlay_dir()
|
||||
if os.path.isdir(overlay):
|
||||
shutil.rmtree(overlay, ignore_errors=True)
|
||||
_set_op("ytdlp_update", state="idle", progress=0.0, error=None, version=None)
|
||||
return _ytdlp_status()
|
||||
@@ -786,7 +786,13 @@ def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
|
||||
_last_repair_error = f"{type(imp_err).__name__}: {imp_err}"
|
||||
return False
|
||||
dl_kwargs: dict = {"repo_id": checkpoint}
|
||||
endpoint = os.environ.get("HF_ENDPOINT")
|
||||
# Explicit endpoint (HF_ENDPOINT / pref) wins; otherwise the automatic
|
||||
# endpoint selection's cached pick applies (services.endpoint_race).
|
||||
try:
|
||||
from services import endpoint_race
|
||||
endpoint = endpoint_race.effective_endpoint()
|
||||
except Exception: # endpoint resolution must never break the repair
|
||||
endpoint = os.environ.get("HF_ENDPOINT")
|
||||
if endpoint:
|
||||
dl_kwargs["endpoint"] = endpoint
|
||||
if force:
|
||||
@@ -837,8 +843,29 @@ def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
|
||||
checkpoint, attempt, retries, e,
|
||||
)
|
||||
_last_repair_error = f"{type(e).__name__}: {e}"
|
||||
if attempt < retries and backoff:
|
||||
time.sleep(backoff * attempt)
|
||||
if attempt < retries:
|
||||
# Endpoint failover (auto mode only, once per repo per
|
||||
# process — same guard pattern as the snapshot-link rung): a
|
||||
# network-classified repair failure re-races the endpoints so
|
||||
# the next attempt retries on the winner instead of burning
|
||||
# every retry on a dead host. Explicit user endpoints are
|
||||
# never switched.
|
||||
try:
|
||||
from services import endpoint_race
|
||||
if endpoint_race.reselect_after_failure(checkpoint, str(e)):
|
||||
new_ep = endpoint_race.effective_endpoint()
|
||||
if new_ep:
|
||||
dl_kwargs["endpoint"] = new_ep
|
||||
else:
|
||||
dl_kwargs.pop("endpoint", None)
|
||||
logger.info(
|
||||
"Auto-repair of %s: endpoint failover — retrying on %s",
|
||||
checkpoint, new_ep or "https://huggingface.co",
|
||||
)
|
||||
except Exception: # failover must never break the ladder
|
||||
pass
|
||||
if backoff:
|
||||
time.sleep(backoff * attempt)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
"""One-click sidecar-engine provisioner (issue: IndexTTS-2 in-app install).
|
||||
|
||||
Some engines (IndexTTS-2 today; MOSS-v1.5 / dots.tts / Confucius4 are the
|
||||
same shape) can't live in the app venv because they pin a ``transformers``
|
||||
version that conflicts with the parent's ``>=5.3``. They run as sidecars:
|
||||
a source checkout + a dedicated venv + (for IndexTTS-2) model weights in
|
||||
``<checkout>/checkpoints/``. Until now provisioning that trio was four
|
||||
manual terminal steps; this module turns it into a resumable background
|
||||
job the Settings → Engines UI can start and poll.
|
||||
|
||||
Design notes (single source of truth for the choices):
|
||||
|
||||
* **Fetch: git primary, tarball fallback.** ``git clone --depth 1`` is the
|
||||
primary path (fast, matches the documented manual flow, and leaves a
|
||||
repo the user can update). When git is absent — common on Windows — we
|
||||
fall back to downloading the GitHub source tarball over HTTPS (httpx,
|
||||
honours proxy env vars) and extracting it with :mod:`tarfile`. A
|
||||
``pip install git+https://…`` path was rejected because the engine
|
||||
*directory* must exist on disk anyway: the sidecar resolves its venv
|
||||
and model weights relative to it.
|
||||
* **Managed install root:** ``DATA_DIR/engines/<engine_id>/`` — always
|
||||
user-writable (works in frozen/packaged builds where ``backend/`` is
|
||||
read-only), survives app updates, and never collides with a user's own
|
||||
clone. A user-managed install (env var already pointing at their clone)
|
||||
is left completely alone.
|
||||
* **Weights ARE part of the install** for engines whose sidecar loads
|
||||
from ``<checkout>/<weights_subdir>/`` (IndexTTS-2's ``main.py`` reads
|
||||
``$OMNIVOICE_INDEXTTS_DIR/checkpoints/config.yaml`` — verified). The
|
||||
download goes through ``huggingface_hub.snapshot_download`` with the
|
||||
endpoint from :mod:`services.endpoint_race` (HF endpoint auto-select;
|
||||
**no hardcoded huggingface.co**) and the token from
|
||||
:mod:`services.token_resolver`.
|
||||
* **Idempotent + resumable:** every step no-ops when its output is
|
||||
already healthy and repairs it when it is half-there (a checkout
|
||||
without ``pyproject.toml`` is re-fetched; a venv that can't import the
|
||||
probe module is re-installed; ``snapshot_download`` resumes weights).
|
||||
* **Persistence:** on success the checkout path is written to
|
||||
``os.environ[<env_var>]`` (the engine's bootstrap reads the env var, so
|
||||
it works immediately — no restart) and to ``prefs.json`` under
|
||||
``env.<env_var>`` (restored into the environment at startup by
|
||||
``main.py``), the same mechanism Settings' env panel uses.
|
||||
|
||||
Cross-platform: no symlinks, no shell strings (argv lists only), venv
|
||||
layout resolved per-OS (``Scripts/python.exe`` vs ``bin/python``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger("omnivoice.sidecar_install")
|
||||
|
||||
_GIB = 1024 ** 3
|
||||
|
||||
# Headroom kept free on the target volume on top of the estimated install
|
||||
# size. Same needs-X/have-Y message shape as the model-install guard
|
||||
# (api.routers.setup.models.disk_space_error), but a deliberately SMALLER
|
||||
# floor than its MIN_FREE_GB=10: required_bytes here is already a
|
||||
# conservative over-estimate, so stacking the full model-cache headroom on
|
||||
# top would block legitimate installs on ~15 GB-free machines.
|
||||
MIN_FREE_GB = 5
|
||||
|
||||
# Bounded in-memory log per job (last N lines survive; enough for the UI's
|
||||
# log tail and for the failure remediation to quote real output).
|
||||
_LOG_MAX_LINES = 200
|
||||
|
||||
_GIT_CLONE_TIMEOUT_S = 600
|
||||
_TARBALL_TIMEOUT_S = 600
|
||||
_UV_VENV_TIMEOUT_S = 300
|
||||
_UV_PIP_INSTALL_TIMEOUT_S = 3600
|
||||
_IMPORT_PROBE_TIMEOUT_S = 120
|
||||
|
||||
|
||||
# ── Spec ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SidecarSpec:
|
||||
"""Everything the provisioner needs to install one sidecar engine.
|
||||
|
||||
Parametrized so future sidecar engines (MOSS-v1.5, dots.tts,
|
||||
Confucius4) become one SPECS entry, not another installer.
|
||||
"""
|
||||
|
||||
engine_id: str
|
||||
display_name: str
|
||||
repo_url: str # git clone URL (primary fetch path)
|
||||
tarball_url: str # source tarball (fallback when git is absent)
|
||||
checkout_dirname: str # directory name of the checkout under the managed root
|
||||
env_var: str # env var the engine's bootstrap reads (install dir)
|
||||
probe_module: str # python -c "import <probe_module>" proves the venv works
|
||||
weights_repo_id: Optional[str] = None # HF repo downloaded into <checkout>/<weights_subdir>
|
||||
weights_subdir: str = "checkpoints"
|
||||
docs_path: str = "docs/engines" # where the manual-install fallback lives
|
||||
required_bytes: int = 12 * _GIB # conservative source+venv+weights estimate for preflight
|
||||
# Called after a successful install/uninstall so the engine's memoised
|
||||
# venv resolution re-probes (import inside the lambda — never at module load).
|
||||
invalidate: Callable[[], None] = field(default=lambda: None)
|
||||
# Cheap "is a healthy install already present?" probe (file existence only).
|
||||
installed_probe: Callable[[], bool] = field(default=lambda: False)
|
||||
|
||||
|
||||
def _indextts_invalidate() -> None:
|
||||
from engines.indextts import bootstrap
|
||||
bootstrap.invalidate()
|
||||
|
||||
|
||||
def _indextts_installed() -> bool:
|
||||
from engines.indextts.bootstrap import is_indextts_installed
|
||||
return is_indextts_installed()
|
||||
|
||||
|
||||
SPECS: dict[str, SidecarSpec] = {
|
||||
"indextts2": SidecarSpec(
|
||||
engine_id="indextts2",
|
||||
display_name="IndexTTS-2",
|
||||
repo_url="https://github.com/index-tts/index-tts.git",
|
||||
tarball_url="https://github.com/index-tts/index-tts/archive/refs/heads/main.tar.gz",
|
||||
checkout_dirname="index-tts",
|
||||
env_var="OMNIVOICE_INDEXTTS_DIR",
|
||||
probe_module="indextts.infer_v2",
|
||||
weights_repo_id="IndexTeam/IndexTTS-2",
|
||||
weights_subdir="checkpoints",
|
||||
docs_path="docs/engines/indextts.md",
|
||||
# ~0.1 GB source + up to ~6 GB venv (torch + transformers<5) +
|
||||
# ~6 GB weights. Deliberately conservative; the preflight subtracts
|
||||
# whatever a partial install already put on disk.
|
||||
required_bytes=12 * _GIB,
|
||||
invalidate=_indextts_invalidate,
|
||||
installed_probe=_indextts_installed,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_spec(engine_id: str) -> Optional[SidecarSpec]:
|
||||
return SPECS.get(engine_id)
|
||||
|
||||
|
||||
def persistent_env_vars() -> set[str]:
|
||||
"""Env vars the provisioner persists — merged into the Settings env-var
|
||||
allowlist (api.routers.system.PERSISTENT_KEYS) so users can inspect or
|
||||
clear them from the same panel as every other persisted var."""
|
||||
return {s.env_var for s in SPECS.values()}
|
||||
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def managed_root(spec: SidecarSpec) -> Path:
|
||||
"""Per-engine managed install root (checkout lives inside it)."""
|
||||
return Path(DATA_DIR) / "engines" / spec.engine_id
|
||||
|
||||
|
||||
def managed_checkout(spec: SidecarSpec) -> Path:
|
||||
return managed_root(spec) / spec.checkout_dirname
|
||||
|
||||
|
||||
def _venv_python(venv_dir: Path) -> Path:
|
||||
"""Venv python path, per-OS layout (Windows: Scripts/, POSIX: bin/)."""
|
||||
if sys.platform == "win32":
|
||||
return venv_dir / "Scripts" / "python.exe"
|
||||
return venv_dir / "bin" / "python"
|
||||
|
||||
|
||||
def _locate_uv() -> Optional[str]:
|
||||
"""Find uv: bundled (Tauri-set OMNIVOICE_BUNDLED_UV) first, then PATH.
|
||||
|
||||
Same resolution order as engines.indextts.bootstrap._locate_uv — the
|
||||
canonical uv-resolution pattern for sidecar venvs.
|
||||
"""
|
||||
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
|
||||
if bundled and Path(bundled).is_file():
|
||||
return bundled
|
||||
return shutil.which("uv")
|
||||
|
||||
|
||||
# ── Disk preflight ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _dir_size_bytes(path: Path) -> int:
|
||||
"""Best-effort recursive size (bytes already spent by a partial install)."""
|
||||
total = 0
|
||||
try:
|
||||
for root, _dirs, files in os.walk(path):
|
||||
for f in files:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(root, f))
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass # unreadable dir — treat as zero bytes spent
|
||||
return total
|
||||
|
||||
|
||||
def disk_free_bytes(path: Path) -> int:
|
||||
"""Free bytes on the volume backing *path* (nearest existing ancestor).
|
||||
Never raises; 0 when the volume can't be probed."""
|
||||
try:
|
||||
p = path.resolve()
|
||||
while not p.exists():
|
||||
parent = p.parent
|
||||
if parent == p:
|
||||
break
|
||||
p = parent
|
||||
return int(shutil.disk_usage(str(p)).free)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def disk_space_error(spec: SidecarSpec) -> Optional[str]:
|
||||
"""Actionable message when the estimated remaining install won't fit
|
||||
(needs X + headroom Y, have Z — same shape as the model-install guard);
|
||||
``None`` when it fits or the volume can't be probed."""
|
||||
root = managed_root(spec)
|
||||
already = _dir_size_bytes(root)
|
||||
remaining = max(0, spec.required_bytes - already)
|
||||
free = disk_free_bytes(root)
|
||||
if free <= 0:
|
||||
return None # can't probe → never block on missing information
|
||||
required = remaining + MIN_FREE_GB * _GIB
|
||||
if free >= required:
|
||||
return None
|
||||
|
||||
def _gb(n: int) -> str:
|
||||
return f"{n / _GIB:.1f} GB"
|
||||
|
||||
return (
|
||||
f"Not enough disk space to install {spec.display_name}: it needs about "
|
||||
f"{_gb(remaining)} plus {MIN_FREE_GB} GB free headroom ({_gb(required)} total), "
|
||||
f"but only {_gb(free)} is free at {root}. Free up space and retry."
|
||||
)
|
||||
|
||||
|
||||
# ── Job state ──────────────────────────────────────────────────────────────
|
||||
|
||||
STEP_IDS = (
|
||||
"preflight",
|
||||
"fetch_source",
|
||||
"create_venv",
|
||||
"install_deps",
|
||||
"verify",
|
||||
"fetch_weights",
|
||||
"persist",
|
||||
)
|
||||
|
||||
_jobs: dict[str, dict] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
|
||||
# Guards each job's log deque: the worker thread appends while the status
|
||||
# poll copies it, and list() over a deque raises RuntimeError if it mutates
|
||||
# mid-iteration. One module-level lock is plenty — appends are tiny and at
|
||||
# most one job runs per engine.
|
||||
_log_lock = threading.Lock()
|
||||
|
||||
|
||||
class _StepError(Exception):
|
||||
"""Install-step failure carrying user-facing remediation text."""
|
||||
|
||||
def __init__(self, message: str, remediation: str):
|
||||
super().__init__(message)
|
||||
self.remediation = remediation
|
||||
|
||||
|
||||
def _new_job(engine_id: str) -> dict:
|
||||
return {
|
||||
"engine_id": engine_id,
|
||||
"state": "running",
|
||||
"steps": [{"id": s, "state": "pending", "detail": None} for s in STEP_IDS],
|
||||
"log": deque(maxlen=_LOG_MAX_LINES),
|
||||
"error": None,
|
||||
"remediation": None,
|
||||
"weights_progress": None,
|
||||
"started_at": time.time(),
|
||||
"finished_at": None,
|
||||
}
|
||||
|
||||
|
||||
def _job_step(job: dict, step_id: str) -> dict:
|
||||
return next(s for s in job["steps"] if s["id"] == step_id)
|
||||
|
||||
|
||||
def _log(job: dict, line: str) -> None:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
with _log_lock:
|
||||
job["log"].append(line)
|
||||
logger.info("[%s install] %s", job["engine_id"], line)
|
||||
|
||||
|
||||
def _serialize_job(job: Optional[dict]) -> Optional[dict]:
|
||||
if job is None:
|
||||
return None
|
||||
out = dict(job)
|
||||
with _log_lock:
|
||||
out["log"] = list(job["log"])
|
||||
out["steps"] = [dict(s) for s in job["steps"]]
|
||||
return out
|
||||
|
||||
|
||||
def get_status(engine_id: str) -> dict:
|
||||
"""Install state + last/current job for one engine. Cheap (file probes)."""
|
||||
spec = get_spec(engine_id)
|
||||
if spec is None:
|
||||
raise KeyError(engine_id)
|
||||
with _jobs_lock:
|
||||
job = _serialize_job(_jobs.get(engine_id))
|
||||
installed = _healthy(spec)
|
||||
checkout = managed_checkout(spec)
|
||||
env_dir = os.environ.get(spec.env_var)
|
||||
return {
|
||||
"engine_id": engine_id,
|
||||
"installed": installed,
|
||||
# True when the on-disk install is the app-managed one (uninstallable
|
||||
# from the app). A user's own clone is never "managed".
|
||||
"managed": bool(
|
||||
checkout.is_dir()
|
||||
and (not env_dir or Path(env_dir) == checkout)
|
||||
),
|
||||
"install_dir": env_dir or (str(checkout) if checkout.is_dir() else None),
|
||||
"job": job,
|
||||
}
|
||||
|
||||
|
||||
def _safe_installed(spec: SidecarSpec) -> bool:
|
||||
try:
|
||||
return bool(spec.installed_probe())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _user_managed_dir(spec: SidecarSpec) -> Optional[Path]:
|
||||
"""The user's own install dir when the env var points anywhere but the
|
||||
app-managed checkout; None for managed/unset (ours to provision)."""
|
||||
env_dir = os.environ.get(spec.env_var)
|
||||
if env_dir and Path(env_dir) != managed_checkout(spec):
|
||||
return Path(env_dir)
|
||||
return None
|
||||
|
||||
|
||||
def _healthy(spec: SidecarSpec) -> bool:
|
||||
"""A COMPLETE install: for a user-managed dir, trust the engine's own
|
||||
probe (their clone, their layout); for the app-managed install require
|
||||
the venv AND the fully-downloaded weights, so a partial install repairs
|
||||
instead of reporting already_installed."""
|
||||
if _user_managed_dir(spec) is not None:
|
||||
return _safe_installed(spec)
|
||||
checkout = managed_checkout(spec)
|
||||
if not checkout.is_dir():
|
||||
# No managed install at all. A legacy install may still exist (e.g.
|
||||
# IndexTTS's old lazy-bootstrap venv under backend/engines/) — trust
|
||||
# the engine's own probe so we never re-provision over a working one.
|
||||
return _safe_installed(spec)
|
||||
if not _venv_python(checkout / ".venv").is_file():
|
||||
return False
|
||||
if spec.weights_repo_id and not _weights_present(spec):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _persist(spec: SidecarSpec) -> None:
|
||||
"""Point the engine at the managed checkout: process env for immediate
|
||||
use, prefs.json ``env.*`` for the next launch, and invalidate the
|
||||
engine's memoised venv resolution so it re-probes without a restart."""
|
||||
checkout = managed_checkout(spec)
|
||||
os.environ[spec.env_var] = str(checkout)
|
||||
from core import prefs
|
||||
prefs.set_(f"env.{spec.env_var}", str(checkout))
|
||||
try:
|
||||
spec.invalidate()
|
||||
except Exception:
|
||||
pass # best-effort cache invalidation — the env var is already set
|
||||
|
||||
|
||||
def start_install(engine_id: str) -> dict:
|
||||
"""Start (or report) the install job for *engine_id*.
|
||||
|
||||
Returns ``{"status": "started"|"already_running"|"already_installed", ...}``.
|
||||
Raises KeyError for an engine with no sidecar spec.
|
||||
"""
|
||||
spec = get_spec(engine_id)
|
||||
if spec is None:
|
||||
raise KeyError(engine_id)
|
||||
with _jobs_lock:
|
||||
existing = _jobs.get(engine_id)
|
||||
if existing and existing["state"] == "running":
|
||||
return {"status": "already_running", "engine": engine_id}
|
||||
# A healthy install (user-managed or app-managed) never reinstalls;
|
||||
# a PARTIAL managed install falls through so the job repairs it.
|
||||
if _healthy(spec):
|
||||
# Self-heal: a healthy MANAGED install whose env var was lost
|
||||
# (e.g. prefs.json wiped) just needs re-pointing, not a reinstall.
|
||||
if (
|
||||
_user_managed_dir(spec) is None
|
||||
and _venv_python(managed_checkout(spec) / ".venv").is_file()
|
||||
and not _safe_installed(spec)
|
||||
):
|
||||
_persist(spec)
|
||||
return {"status": "already_installed", "engine": engine_id}
|
||||
job = _new_job(engine_id)
|
||||
_jobs[engine_id] = job
|
||||
th = threading.Thread(
|
||||
target=_run_install, args=(spec, job),
|
||||
name=f"sidecar-install-{engine_id}", daemon=True,
|
||||
)
|
||||
th.start()
|
||||
return {"status": "started", "engine": engine_id}
|
||||
|
||||
|
||||
def uninstall(engine_id: str) -> dict:
|
||||
"""Remove the app-managed install and clear the persisted path.
|
||||
|
||||
Refuses to touch a user-managed install (env var pointing anywhere but
|
||||
the managed checkout) — those were never ours to delete.
|
||||
"""
|
||||
spec = get_spec(engine_id)
|
||||
if spec is None:
|
||||
raise KeyError(engine_id)
|
||||
with _jobs_lock:
|
||||
job = _jobs.get(engine_id)
|
||||
if job and job["state"] == "running":
|
||||
return {"status": "install_in_progress", "engine": engine_id}
|
||||
env_dir = os.environ.get(spec.env_var)
|
||||
checkout = managed_checkout(spec)
|
||||
if env_dir and Path(env_dir) != checkout:
|
||||
return {
|
||||
"status": "not_managed",
|
||||
"engine": engine_id,
|
||||
"detail": (
|
||||
f"{spec.display_name} points at {env_dir}, which OmniVoice did not "
|
||||
f"install. Remove that directory yourself if you want it gone, or "
|
||||
f"clear {spec.env_var} in Settings."
|
||||
),
|
||||
}
|
||||
root = managed_root(spec)
|
||||
removed = root.is_dir()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
if env_dir: # only ever the managed checkout at this point
|
||||
os.environ.pop(spec.env_var, None)
|
||||
from core import prefs
|
||||
if prefs.get(f"env.{spec.env_var}") == str(checkout):
|
||||
prefs.delete(f"env.{spec.env_var}")
|
||||
try:
|
||||
spec.invalidate()
|
||||
except Exception:
|
||||
pass # best-effort cache invalidation — uninstall already succeeded
|
||||
with _jobs_lock:
|
||||
_jobs.pop(engine_id, None)
|
||||
return {"status": "uninstalled" if removed else "not_installed", "engine": engine_id}
|
||||
|
||||
|
||||
# ── Worker ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_install(spec: SidecarSpec, job: dict) -> None:
|
||||
step_fns: list[tuple[str, Callable[[SidecarSpec, dict], None]]] = [
|
||||
("preflight", _step_preflight),
|
||||
("fetch_source", _step_fetch_source),
|
||||
("create_venv", _step_create_venv),
|
||||
("install_deps", _step_install_deps),
|
||||
("verify", _step_verify),
|
||||
("fetch_weights", _step_fetch_weights),
|
||||
("persist", _step_persist),
|
||||
]
|
||||
try:
|
||||
for step_id, fn in step_fns:
|
||||
step = _job_step(job, step_id)
|
||||
step["state"] = "running"
|
||||
try:
|
||||
fn(spec, job)
|
||||
except _StepError:
|
||||
step["state"] = "error"
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — surfaced into the job
|
||||
step["state"] = "error"
|
||||
raise _StepError(
|
||||
f"{type(exc).__name__}: {exc}",
|
||||
"Re-run the install — it resumes from where it stopped. If it "
|
||||
f"keeps failing, see {spec.docs_path} for the manual steps.",
|
||||
) from exc
|
||||
if step["state"] == "running":
|
||||
step["state"] = "done"
|
||||
job["state"] = "succeeded"
|
||||
_log(job, f"{spec.display_name} installed successfully.")
|
||||
except _StepError as exc:
|
||||
job["state"] = "failed"
|
||||
job["error"] = str(exc)
|
||||
job["remediation"] = exc.remediation
|
||||
_log(job, f"FAILED: {exc}")
|
||||
finally:
|
||||
job["finished_at"] = time.time()
|
||||
|
||||
|
||||
def _step_preflight(spec: SidecarSpec, job: dict) -> None:
|
||||
if _locate_uv() is None:
|
||||
raise _StepError(
|
||||
"uv was not found (checked the bundled path via OMNIVOICE_BUNDLED_UV, "
|
||||
"then PATH).",
|
||||
"Install uv from https://docs.astral.sh/uv/ and relaunch OmniVoice, or "
|
||||
"set OMNIVOICE_BUNDLED_UV to the absolute path of a uv binary.",
|
||||
)
|
||||
err = disk_space_error(spec)
|
||||
if err:
|
||||
raise _StepError(err, "Free up disk space (or move OmniVoice's data directory "
|
||||
"to a larger volume) and retry.")
|
||||
managed_root(spec).mkdir(parents=True, exist_ok=True)
|
||||
_job_step(job, "preflight")["detail"] = "uv found, disk space OK"
|
||||
_log(job, "Preflight OK — uv resolved and enough free disk space.")
|
||||
|
||||
|
||||
def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
|
||||
step = _job_step(job, "fetch_source")
|
||||
checkout = managed_checkout(spec)
|
||||
if (checkout / "pyproject.toml").is_file():
|
||||
step["state"] = "done"
|
||||
step["detail"] = "source already present"
|
||||
_log(job, f"Source already present at {checkout} — skipping fetch.")
|
||||
return
|
||||
if checkout.exists():
|
||||
# Half-fetched checkout (no pyproject.toml) — repair by refetching.
|
||||
_log(job, f"Removing incomplete checkout at {checkout} …")
|
||||
shutil.rmtree(checkout, ignore_errors=True)
|
||||
|
||||
git = shutil.which("git")
|
||||
if git:
|
||||
_log(job, f"Cloning {spec.repo_url} (git, depth 1) …")
|
||||
rc = _run_logged(job, [git, "clone", "--depth", "1", spec.repo_url, str(checkout)],
|
||||
timeout=_GIT_CLONE_TIMEOUT_S)
|
||||
if rc == 0 and (checkout / "pyproject.toml").is_file():
|
||||
step["detail"] = "git clone"
|
||||
return
|
||||
_log(job, f"git clone failed (exit {rc}) — falling back to source tarball.")
|
||||
shutil.rmtree(checkout, ignore_errors=True)
|
||||
else:
|
||||
_log(job, "git not found — using the source-tarball fallback.")
|
||||
|
||||
_fetch_tarball(spec, job, checkout)
|
||||
if not (checkout / "pyproject.toml").is_file():
|
||||
raise _StepError(
|
||||
f"Fetched source at {checkout} has no pyproject.toml — the download "
|
||||
"appears incomplete or the upstream layout changed.",
|
||||
"Re-run the install; if it keeps failing, clone the repository "
|
||||
f"manually and set {spec.env_var} to the clone (see the engine docs).",
|
||||
)
|
||||
step["detail"] = "source tarball"
|
||||
|
||||
|
||||
def _fetch_tarball(spec: SidecarSpec, job: dict, checkout: Path) -> None:
|
||||
"""Download + extract the GitHub source tarball (no git required).
|
||||
|
||||
Extraction is member-validated (no absolute paths / parent escapes) and
|
||||
never uses symlinks, so it behaves identically on Windows.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
root = managed_root(spec)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
_log(job, f"Downloading {spec.tarball_url} …")
|
||||
fd, tmp_tar = tempfile.mkstemp(suffix=".tar.gz", dir=str(root))
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
with httpx.stream(
|
||||
"GET", spec.tarball_url, follow_redirects=True,
|
||||
timeout=_TARBALL_TIMEOUT_S,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_bytes():
|
||||
out.write(chunk)
|
||||
_log(job, "Extracting source tarball …")
|
||||
with tempfile.TemporaryDirectory(dir=str(root)) as tmp_dir:
|
||||
with tarfile.open(tmp_tar, "r:gz") as tf:
|
||||
try:
|
||||
tf.extractall(tmp_dir, filter="data") # stdlib safe-extract (3.11.4+)
|
||||
except TypeError: # pragma: no cover — pre-filter= interpreters
|
||||
_safe_extract_members(tf, tmp_dir)
|
||||
entries = [p for p in Path(tmp_dir).iterdir() if p.is_dir()]
|
||||
if len(entries) != 1:
|
||||
raise _StepError(
|
||||
f"Unexpected tarball layout ({len(entries)} top-level dirs).",
|
||||
"Re-run the install; if it keeps failing, clone the repository "
|
||||
f"manually and set {spec.env_var} (see the engine docs).",
|
||||
)
|
||||
# os.replace-style move keeps this atomic-ish on the same volume.
|
||||
shutil.move(str(entries[0]), str(checkout))
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_tar)
|
||||
except OSError:
|
||||
pass # temp tarball already gone / locked — harmless leftover
|
||||
|
||||
|
||||
def _safe_extract_members(tf: "tarfile.TarFile", dest: str) -> None:
|
||||
"""Tar-slip-guarded extraction for interpreters without
|
||||
``extractall(filter="data")`` (Python < 3.11.4).
|
||||
|
||||
Mirrors what the "data" filter enforces: only regular files and
|
||||
directories (no symlinks/hardlinks/devices — also keeps Windows
|
||||
behaviour identical), no absolute paths, and every resolved target must
|
||||
stay inside *dest*.
|
||||
"""
|
||||
dest_abs = os.path.abspath(dest)
|
||||
for member in tf.getmembers():
|
||||
if not (member.isreg() or member.isdir()):
|
||||
continue # drop symlinks/hardlinks/devices/fifos
|
||||
name = member.name
|
||||
if name.startswith(("/", "\\")) or ".." in name.replace("\\", "/").split("/"):
|
||||
continue # absolute path or parent-dir escape
|
||||
target = os.path.abspath(os.path.join(dest, name))
|
||||
if os.path.commonpath([dest_abs, target]) != dest_abs:
|
||||
continue # resolved outside the extraction dir
|
||||
tf.extract(member, dest)
|
||||
|
||||
|
||||
def _step_create_venv(spec: SidecarSpec, job: dict) -> None:
|
||||
step = _job_step(job, "create_venv")
|
||||
checkout = managed_checkout(spec)
|
||||
venv_dir = checkout / ".venv"
|
||||
py = _venv_python(venv_dir)
|
||||
if py.is_file():
|
||||
step["state"] = "done"
|
||||
step["detail"] = "venv already present"
|
||||
_log(job, f"Venv already present at {venv_dir} — skipping.")
|
||||
return
|
||||
uv = _locate_uv()
|
||||
_log(job, f"Creating venv at {venv_dir} …")
|
||||
rc = _run_logged(job, [uv, "venv", str(venv_dir)], timeout=_UV_VENV_TIMEOUT_S)
|
||||
if rc != 0 or not py.is_file():
|
||||
raise _StepError(
|
||||
f"uv venv failed (exit {rc}) at {venv_dir}.",
|
||||
"Check the log above for the uv error; free disk space or fix "
|
||||
"permissions on the data directory, then re-run the install.",
|
||||
)
|
||||
step["detail"] = "venv created"
|
||||
|
||||
|
||||
def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
|
||||
"""`uv pip install -e <checkout>` into the dedicated venv.
|
||||
|
||||
Deliberately NOT `uv sync` — sync would apply the sidecar's lockfile
|
||||
semantics; `uv pip install -e` resolves the sidecar's own pins
|
||||
(e.g. transformers<5) inside ITS venv, never touching the parent app.
|
||||
Idempotent: re-running repairs a partial dependency set.
|
||||
"""
|
||||
checkout = managed_checkout(spec)
|
||||
py = _venv_python(checkout / ".venv")
|
||||
uv = _locate_uv()
|
||||
_log(job, f"Installing {spec.display_name} into its venv (this can take several minutes) …")
|
||||
rc = _run_logged(
|
||||
job,
|
||||
[uv, "pip", "install", "--python", str(py), "-e", str(checkout)],
|
||||
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
|
||||
)
|
||||
if rc != 0:
|
||||
raise _StepError(
|
||||
f"uv pip install -e failed (exit {rc}).",
|
||||
"Usually a network hiccup — re-run the install to resume. Behind a "
|
||||
"proxy, set HTTPS_PROXY in Settings → Environment first.",
|
||||
)
|
||||
_job_step(job, "install_deps")["detail"] = "dependencies installed"
|
||||
|
||||
|
||||
def _step_verify(spec: SidecarSpec, job: dict) -> None:
|
||||
checkout = managed_checkout(spec)
|
||||
py = _venv_python(checkout / ".venv")
|
||||
_log(job, f"Verifying `import {spec.probe_module}` inside the venv …")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(py), "-c", f"import {spec.probe_module}"],
|
||||
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
raise _StepError(
|
||||
f"Import probe failed to run: {exc}",
|
||||
"Re-run the install; if it keeps failing, delete the engine in "
|
||||
"Settings → Engines and install again.",
|
||||
) from exc
|
||||
if proc.returncode != 0:
|
||||
tail = proc.stderr.decode("utf-8", errors="replace")[-500:]
|
||||
raise _StepError(
|
||||
f"`import {spec.probe_module}` failed in the new venv: {tail}",
|
||||
"Re-run the install — dependency resolution resumes and repairs "
|
||||
"partial installs. If it keeps failing, use the manual install in "
|
||||
"the engine docs.",
|
||||
)
|
||||
_job_step(job, "verify")["detail"] = f"import {spec.probe_module} OK"
|
||||
_log(job, "Venv verified.")
|
||||
|
||||
|
||||
# Written into the weights dir after snapshot_download COMPLETES. A partial
|
||||
# multi-shard download can leave config.yaml + several plausible shards on
|
||||
# disk, so file heuristics alone would declare a killed-mid-download install
|
||||
# healthy and never resume it (the sidecar edition of #352). Only this
|
||||
# installer writes the marker; user-managed clones never hit this path.
|
||||
_WEIGHTS_COMPLETE_MARKER = ".omnivoice_weights_complete"
|
||||
|
||||
|
||||
def _weights_present(spec: SidecarSpec) -> bool:
|
||||
"""True only for a COMPLETED weights download: the completion marker
|
||||
plus a sanity floor (config.yaml + one ≥5 MB weight file — the same
|
||||
truncated-download floor the model store uses)."""
|
||||
wdir = managed_checkout(spec) / spec.weights_subdir
|
||||
if not (wdir / _WEIGHTS_COMPLETE_MARKER).is_file():
|
||||
return False
|
||||
return _weights_floor_ok(wdir)
|
||||
|
||||
|
||||
def _weights_floor_ok(wdir: Path) -> bool:
|
||||
if not (wdir / "config.yaml").is_file():
|
||||
return False
|
||||
floor = 5 * 1024 * 1024
|
||||
try:
|
||||
for root, _dirs, files in os.walk(wdir):
|
||||
for f in files:
|
||||
try:
|
||||
if os.path.getsize(os.path.join(root, f)) >= floor:
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass # unreadable weights dir — treat as not present
|
||||
return False
|
||||
|
||||
|
||||
def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
|
||||
step = _job_step(job, "fetch_weights")
|
||||
if not spec.weights_repo_id:
|
||||
step["state"] = "skipped"
|
||||
step["detail"] = "engine has no bundled-weights requirement"
|
||||
return
|
||||
if _weights_present(spec):
|
||||
step["state"] = "done"
|
||||
step["detail"] = "weights already present"
|
||||
_log(job, "Model weights already present — skipping download.")
|
||||
return
|
||||
|
||||
wdir = managed_checkout(spec) / spec.weights_subdir
|
||||
wdir.mkdir(parents=True, exist_ok=True)
|
||||
_log(job, f"Downloading {spec.weights_repo_id} → {wdir} (several GB — resumable) …")
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
from services import endpoint_race
|
||||
from services.token_resolver import resolve as resolve_token
|
||||
from utils import hf_progress
|
||||
|
||||
# Mirror per-file byte progress into the job so the polling UI can show
|
||||
# it — same tqdm hook the model store's SSE feed uses.
|
||||
def _listener(ev: dict) -> None:
|
||||
try:
|
||||
# Only mirror events for OUR repo — a concurrent model-store
|
||||
# download must not scribble its progress into this job.
|
||||
if ev.get("repo_id") not in (None, spec.weights_repo_id):
|
||||
return
|
||||
job["weights_progress"] = {
|
||||
"filename": ev.get("filename"),
|
||||
"downloaded": ev.get("downloaded"),
|
||||
"total": ev.get("total"),
|
||||
"pct": ev.get("pct"),
|
||||
}
|
||||
except Exception:
|
||||
pass # progress mirroring is advisory — never break the download
|
||||
|
||||
listener_id = hf_progress.register_listener(_listener)
|
||||
repo_token = hf_progress.current_repo_id.set(spec.weights_repo_id)
|
||||
try:
|
||||
# Tracks the repo's default branch on purpose (same policy as every
|
||||
# other model download in the app — see setup/download.py): the
|
||||
# source checkout is unpinned upstream `main` anyway, and hf_hub
|
||||
# checksum-verifies each artifact. Hence the B615 waiver below.
|
||||
kwargs: dict = {
|
||||
"repo_id": spec.weights_repo_id,
|
||||
"local_dir": str(wdir),
|
||||
"token": resolve_token(),
|
||||
}
|
||||
endpoint = endpoint_race.effective_endpoint()
|
||||
if endpoint:
|
||||
kwargs["endpoint"] = endpoint
|
||||
tqdm_cls = hf_progress.tracked_tqdm_class()
|
||||
if tqdm_cls is not None:
|
||||
kwargs["tqdm_class"] = tqdm_cls
|
||||
try:
|
||||
snapshot_download(**kwargs) # nosec B615 — deliberate default-branch policy, see above
|
||||
except Exception as exc:
|
||||
raise _StepError(
|
||||
f"Model weight download failed: {exc}",
|
||||
"Re-run the install — the download resumes where it stopped. "
|
||||
"Check Settings → Network (HF endpoint / proxy) if it keeps failing.",
|
||||
) from exc
|
||||
finally:
|
||||
hf_progress.unregister_listener(listener_id)
|
||||
hf_progress.current_repo_id.reset(repo_token)
|
||||
|
||||
if not _weights_floor_ok(wdir):
|
||||
raise _StepError(
|
||||
"Weight download finished but no plausible weight files were found — "
|
||||
"the download was likely interrupted.",
|
||||
"Re-run the install to resume the download.",
|
||||
)
|
||||
# snapshot_download returned AND the sanity floor holds → mark complete,
|
||||
# so _weights_present/_healthy stop treating this dir as a partial.
|
||||
(wdir / _WEIGHTS_COMPLETE_MARKER).write_text(
|
||||
f"{spec.weights_repo_id}\n{time.time():.0f}\n", encoding="utf-8",
|
||||
)
|
||||
step["detail"] = "weights downloaded"
|
||||
_log(job, "Model weights downloaded.")
|
||||
|
||||
|
||||
def _step_persist(spec: SidecarSpec, job: dict) -> None:
|
||||
_persist(spec)
|
||||
_job_step(job, "persist")["detail"] = f"{spec.env_var}={managed_checkout(spec)}"
|
||||
_log(job, f"Saved {spec.env_var} — the engine is ready to use, no restart needed.")
|
||||
|
||||
|
||||
# ── Subprocess runner with live log capture ────────────────────────────────
|
||||
|
||||
|
||||
def _run_logged(job: dict, argv: list[str], *, timeout: float) -> int:
|
||||
"""Run *argv*, streaming combined stdout+stderr lines into the job log.
|
||||
|
||||
Returns the exit code; -1 on timeout (process tree killed) or spawn
|
||||
failure. argv-list only — never a shell string — so paths with spaces
|
||||
are safe on every platform.
|
||||
|
||||
The stdout drain runs on its own daemon thread and the main flow blocks
|
||||
on ``proc.wait(timeout=…)``. That bounds the step even when a grandchild
|
||||
(uv resolver worker, git helper) inherits the pipe and outlives the
|
||||
killed child — a blocking ``for line in proc.stdout`` on this thread
|
||||
would hang past the timeout waiting for pipe EOF.
|
||||
"""
|
||||
popen_kwargs: dict = {}
|
||||
if os.name == "posix":
|
||||
# New session → we can kill the whole process group on timeout
|
||||
# instead of only the direct child.
|
||||
popen_kwargs["start_new_session"] = True
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
**popen_kwargs,
|
||||
)
|
||||
except OSError as exc:
|
||||
_log(job, f"failed to spawn {argv[0]}: {exc}")
|
||||
return -1
|
||||
|
||||
def _drain() -> None:
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
_log(job, line)
|
||||
except (OSError, ValueError):
|
||||
pass # pipe closed by the timeout kill — nothing left to read
|
||||
|
||||
drain = threading.Thread(target=_drain, daemon=True)
|
||||
drain.start()
|
||||
try:
|
||||
rc = proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_tree(proc)
|
||||
_log(job, f"process timed out after {timeout:.0f}s — killed")
|
||||
return -1
|
||||
drain.join(5.0) # give the drain a moment to flush the tail
|
||||
return rc if rc is not None else -1
|
||||
|
||||
|
||||
def _kill_tree(proc: "subprocess.Popen") -> None:
|
||||
"""Kill the child and its whole process tree, on every platform.
|
||||
|
||||
POSIX: the child was started in its own session, so SIGKILL the group.
|
||||
Windows: ``proc.kill()`` only terminates the direct child — a git/uv
|
||||
helper it spawned would keep running (and writing into the checkout)
|
||||
past our timeout — so use ``taskkill /T`` to fell the tree.
|
||||
"""
|
||||
if os.name == "posix":
|
||||
import signal
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
return
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass # group already gone / not ours — fall through to plain kill
|
||||
else: # Windows
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
return
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass # taskkill unavailable/failed — fall through to plain kill
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass # process already exited
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPECS",
|
||||
"SidecarSpec",
|
||||
"disk_space_error",
|
||||
"get_spec",
|
||||
"get_status",
|
||||
"managed_checkout",
|
||||
"managed_root",
|
||||
"persistent_env_vars",
|
||||
"start_install",
|
||||
"uninstall",
|
||||
]
|
||||
@@ -469,3 +469,39 @@ def clear_cache() -> None:
|
||||
"""Testing hook — drop the in-process cache."""
|
||||
with _cache_lock:
|
||||
_cache.update(key=None, ts=0.0, report=None)
|
||||
|
||||
|
||||
def clear_temp(temp_root: str | None = None) -> dict:
|
||||
"""Delete the app-owned ``omnivoice*`` entries in the OS temp dir.
|
||||
|
||||
Removes exactly the population ``build_report`` counts as the "temp"
|
||||
category — direct children of ``temp_root`` whose basename starts with
|
||||
``omnivoice`` — so nothing outside OmniVoice's own working files can ever
|
||||
be swept up. Symlinked entries are unlinked, never followed, so a stray
|
||||
``omnivoice*`` link cannot make this delete its target's contents.
|
||||
|
||||
Returns ``{"removed": [basenames], "freed_bytes": int, "errors":
|
||||
[{"path", "error"}]}`` — partial failures (e.g. a file held open by a
|
||||
running job on Windows) are reported per entry instead of aborting.
|
||||
"""
|
||||
temp_root = temp_root if temp_root is not None else tempfile.gettempdir()
|
||||
removed: list[str] = []
|
||||
errors: list[dict] = []
|
||||
freed = 0
|
||||
deadline = time.monotonic() + CATEGORY_TIMEOUT_SECONDS
|
||||
for p in sorted(glob.glob(os.path.join(glob.escape(temp_root), "omnivoice*"))):
|
||||
try:
|
||||
if os.path.islink(p):
|
||||
size = 0
|
||||
os.unlink(p)
|
||||
elif os.path.isfile(p):
|
||||
size = os.path.getsize(p)
|
||||
os.unlink(p)
|
||||
else:
|
||||
size, _complete, _err = _dir_size(p, deadline)
|
||||
shutil.rmtree(p)
|
||||
removed.append(os.path.basename(p))
|
||||
freed += size
|
||||
except OSError as e:
|
||||
errors.append({"path": p, "error": str(e)})
|
||||
return {"removed": removed, "freed_bytes": freed, "errors": errors}
|
||||
|
||||
@@ -30,7 +30,7 @@ logger = logging.getLogger("omnivoice.token_resolver")
|
||||
Source = Literal["app", "env", "hf-cli"]
|
||||
_PRIORITY: tuple[Source, ...] = ("app", "env", "hf-cli")
|
||||
|
||||
_CACHE_TTL_SECONDS = 300.0 # See Open Question #4 — UI "Test now" calls invalidate.
|
||||
_CACHE_TTL_SECONDS = 300.0 # UI "Test now" busts it via GET /hf-token/state?fresh=1.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -57,7 +57,8 @@ _CACHE_LOCK = threading.Lock()
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
"""Drop the whoami validation cache. Called by the Settings UI "Test now"
|
||||
button (Plan 01-02) and by save/clear API endpoints (Task 3)."""
|
||||
button (GET /api/settings/hf-token/state?fresh=1), by save/clear API
|
||||
endpoints, and by on_401()."""
|
||||
with _CACHE_LOCK:
|
||||
_VALIDATION_CACHE.clear()
|
||||
|
||||
|
||||
@@ -55,6 +55,27 @@ def _mask_hf_tokens(value):
|
||||
return _HF_TOKEN_MASK_RE.sub(_HF_TOKEN_MASK, value)
|
||||
|
||||
|
||||
def _available_hint(msg) -> Optional[str]:
|
||||
"""Advisory text carried by an *available* engine's ``is_available()``
|
||||
message, or None when the message is a plain readiness echo.
|
||||
|
||||
Convention (established by VoxCPM2's version-floor hint): an engine
|
||||
that is available but wants the user to know something returns
|
||||
``(True, "ready — <advice>")``. This extracts ``<advice>`` so
|
||||
:func:`list_backends` can surface it — previously the whole message
|
||||
was dropped for available rows (``reason`` is None when ok), so
|
||||
upgrade hints never reached the UI. Plain "ready" / "ready (…)"
|
||||
messages yield None. Output is token-masked like ``reason``.
|
||||
"""
|
||||
if not isinstance(msg, str):
|
||||
return None
|
||||
head, sep, advice = msg.partition(" — ")
|
||||
advice = advice.strip()
|
||||
if not sep or not advice or not head.strip().lower().startswith("ready"):
|
||||
return None
|
||||
return _mask_hf_tokens(advice)
|
||||
|
||||
|
||||
# ── HF Hub closed-client recovery (#880) ────────────────────────────────────
|
||||
#
|
||||
# huggingface_hub ≥1.x shares ONE global httpx client across every download.
|
||||
@@ -1677,6 +1698,23 @@ _MLX_AUDIO_MODEL_LABELS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _sidecar_installable_ids() -> frozenset[str]:
|
||||
"""Engine ids with a one-click sidecar installer. Deferred import — the
|
||||
installer module is tiny, but keeping the import inside the function
|
||||
means a broken/absent installer can never take the engine picker down.
|
||||
|
||||
All current sidecar SPECS are TTS engines, so only this registry carries
|
||||
``one_click_install``; the first non-TTS sidecar engine will need the same
|
||||
field plumbed into asr_backend/llm_backend.list_backends and the Install
|
||||
button into their matrix rows.
|
||||
"""
|
||||
try:
|
||||
from services.sidecar_install import SPECS
|
||||
return frozenset(SPECS)
|
||||
except Exception: # pragma: no cover — defensive only
|
||||
return frozenset()
|
||||
|
||||
|
||||
def list_backends() -> list[dict]:
|
||||
"""Enumerate every registered backend with its availability state.
|
||||
|
||||
@@ -1687,11 +1725,17 @@ def list_backends() -> list[dict]:
|
||||
"display_name": str,
|
||||
"available": bool,
|
||||
"reason": Optional[str], # message when not available
|
||||
"hint": Optional[str], # advice when available-but-has-advice
|
||||
# (is_available "ready — <advice>" convention;
|
||||
# e.g. VoxCPM2's >=2.0.3 upgrade hint)
|
||||
"install_hint": Optional[str],
|
||||
"setup_snippet": Optional[str], # exact `export VAR=...` for path-gated opt-in engines
|
||||
"one_click_install": bool, # services.sidecar_install can provision it in-app
|
||||
"last_error": Optional[str], # cached most-recent failure
|
||||
"isolation_mode": "in-process" | "subprocess",
|
||||
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, cpu}
|
||||
"supports_cloning": Optional[bool], # True/False from the class attr; None when
|
||||
# model-dependent (property, e.g. mlx-audio)
|
||||
"effective_device": str, # device this engine uses on THIS host
|
||||
"routing_status": "accelerated" | "cpu_fallback" | "cpu_only" | "unavailable",
|
||||
"routing_reason": Optional[str], # scrubbed; null when none
|
||||
@@ -1719,6 +1763,7 @@ def list_backends() -> list[dict]:
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import routing_fields
|
||||
caps = detect_host_caps()
|
||||
installable = _sidecar_installable_ids()
|
||||
|
||||
out: list[dict] = []
|
||||
for bid, cls in _REGISTRY.items():
|
||||
@@ -1746,14 +1791,28 @@ def list_backends() -> list[dict]:
|
||||
else:
|
||||
isolation = "in-process"
|
||||
gpu_compat = getattr(cls, "gpu_compat", ("cpu",))
|
||||
# Cloning capability: same descriptor guard as
|
||||
# cloning_capable_engine_ids() — a class-level getattr on a *property*
|
||||
# (mlx-audio: capability depends on the picked model) returns the
|
||||
# descriptor, not a bool, so report None (= model-dependent) there
|
||||
# instead of an always-truthy false positive.
|
||||
_clone = getattr(cls, "supports_cloning", True)
|
||||
out.append({
|
||||
"id": bid,
|
||||
"display_name": cls.display_name,
|
||||
"available": ok,
|
||||
"reason": None if ok else _mask_hf_tokens(msg),
|
||||
# Available-but-has-advice (e.g. VoxCPM2's ">=2.0.3 recommended"
|
||||
# upgrade hint). None unless ok and the message carries advice.
|
||||
"hint": _available_hint(msg) if ok else None,
|
||||
"supports_cloning": _clone if isinstance(_clone, bool) else None,
|
||||
"install_hint": _INSTALL_HINTS.get(bid),
|
||||
# Exact `export VAR=...` line for path-gated opt-in engines, or None.
|
||||
"setup_snippet": _SETUP_SNIPPETS.get(bid),
|
||||
# True when services.sidecar_install can provision this engine
|
||||
# in-app (Settings renders an Install button instead of leading
|
||||
# with the manual setup snippet).
|
||||
"one_click_install": bid in installable,
|
||||
"last_error": _LAST_ERRORS.get(bid),
|
||||
"isolation_mode": isolation,
|
||||
"gpu_compat": list(gpu_compat),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Shared setup for backend/tests — import path + hermetic data dir.
|
||||
|
||||
Historically every module in this directory stubbed
|
||||
``sys.modules["core.config"]`` with a bare 3-4 attribute ``ModuleType``
|
||||
pointing at its own ``mkdtemp``. That stub leaked **process-wide at
|
||||
collection time**: pytest imports test modules while collecting, so in any
|
||||
mixed invocation (``pytest tests/... backend/tests/...``) every *later* lazy
|
||||
import of ``core.config`` resolved the stub instead of the real module —
|
||||
``tests/test_router_smoke.py``'s ``from main import app`` died with
|
||||
ImportError (missing config attrs), and
|
||||
``monkeypatch.setattr("core.config.X", ...)`` died with AttributeError
|
||||
(``core`` never gets a ``config`` attribute when the name is satisfied
|
||||
straight from ``sys.modules``). That was the root cause of the
|
||||
order-pollution combos around test_longform_e2e (8 AttributeErrors) and
|
||||
test_router_smoke (24 fixture ImportErrors).
|
||||
|
||||
The real ``core.config`` derives every path from ``OMNIVOICE_DATA_DIR`` at
|
||||
import time, so pointing that env var at a throwaway dir *before* any test
|
||||
module imports it gives the same hermeticity (issue #878: never touch the
|
||||
developer's real app state) with zero ``sys.modules`` surgery. This mirrors
|
||||
``tests/conftest.py``; in a mixed run whichever conftest loads first wins
|
||||
(``setdefault`` semantics) and both point at a throwaway tmpdir.
|
||||
|
||||
Do NOT reintroduce module-level ``sys.modules`` stubs in this directory —
|
||||
import the real module and rely on this conftest instead.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Backend runs with `--app-dir backend`, so tests must do the same.
|
||||
_BACKEND = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _BACKEND not in sys.path:
|
||||
sys.path.insert(0, _BACKEND)
|
||||
|
||||
if not os.environ.get("OMNIVOICE_DATA_DIR"):
|
||||
os.environ["OMNIVOICE_DATA_DIR"] = tempfile.mkdtemp(prefix="omnivoice-test-data-")
|
||||
if not os.environ.get("OMNIVOICE_ENV_FILE"):
|
||||
os.environ["OMNIVOICE_ENV_FILE"] = os.path.join(
|
||||
os.environ["OMNIVOICE_DATA_DIR"], "user-env"
|
||||
)
|
||||
@@ -14,24 +14,13 @@ verified manually (spectral flatness back in the speech range + Whisper ASR).
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# Stub core.config before the router imports OUTPUTS_DIR / VOICES_DIR from it.
|
||||
_TMP = tempfile.mkdtemp(prefix="omnivoice_preview_q_")
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = _TMP
|
||||
_config.VOICES_DIR = str(Path(_TMP) / "voices")
|
||||
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
|
||||
sys.modules["core.config"] = _config
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before the router imports OUTPUTS_DIR / VOICES_DIR from
|
||||
# the REAL core.config (the old sys.modules stub leaked at collection time
|
||||
# and broke later importers in mixed runs — see conftest.py).
|
||||
torch = pytest.importorskip("torch") # noqa: E402
|
||||
|
||||
from api.routers import archetypes as arch # noqa: E402
|
||||
|
||||
@@ -9,29 +9,13 @@ generation.py's proven ``_run_inference`` rather than re-implementing it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# Stub core.config before the router imports VOICES_DIR / OUTPUTS_DIR from it.
|
||||
_TMP = tempfile.mkdtemp(prefix="omnivoice_arch_test_")
|
||||
_VOICES = Path(_TMP) / "voices"
|
||||
_OUTPUTS = Path(_TMP) / "outputs"
|
||||
_VOICES.mkdir(parents=True, exist_ok=True)
|
||||
_OUTPUTS.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = _TMP
|
||||
_config.VOICES_DIR = str(_VOICES)
|
||||
_config.OUTPUTS_DIR = str(_OUTPUTS)
|
||||
sys.modules["core.config"] = _config
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before the router imports VOICES_DIR / OUTPUTS_DIR from
|
||||
# the REAL core.config (the old sys.modules stub leaked at collection time).
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
|
||||
@@ -8,21 +8,14 @@ OOM deterministically (no GPU needed) and asserts the device switch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = tempfile.mkdtemp(prefix="omnivoice_asr_oom_")
|
||||
_config.VOICES_DIR = _config.DATA_DIR
|
||||
_config.OUTPUTS_DIR = _config.DATA_DIR
|
||||
sys.modules["core.config"] = _config
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before this module imports the REAL core.config (the old
|
||||
# sys.modules stub leaked at collection time and broke mixed runs).
|
||||
whisperx = pytest.importorskip("whisperx")
|
||||
|
||||
from services.asr_backend import ( # noqa: E402
|
||||
|
||||
@@ -3,32 +3,18 @@
|
||||
is covered by the manifest round-trip (tests/test_longform_resume.py) + the
|
||||
existing render tests, not here.
|
||||
|
||||
Config-stub pattern (mounts only the audiobook router — torch-free at import) so
|
||||
it runs locally without the main+torch segfault.
|
||||
Mounts only the audiobook router (no ``main`` import) so it runs locally
|
||||
without the main+torch segfault; conftest.py provides the hermetic data dir.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="omnivoice_abresume_test_")
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = _TMP
|
||||
_config.VOICES_DIR = str(Path(_TMP) / "voices")
|
||||
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
|
||||
_config.DB_PATH = str(Path(_TMP) / "omnivoice.db")
|
||||
os.makedirs(_config.VOICES_DIR, exist_ok=True)
|
||||
os.makedirs(_config.OUTPUTS_DIR, exist_ok=True)
|
||||
sys.modules["core.config"] = _config
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before this module imports the REAL core.config (the old
|
||||
# sys.modules stub leaked at collection time and broke mixed runs).
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
|
||||
@@ -6,19 +6,11 @@ lightweight — it only imports os, uuid, time, asyncio, logging,
|
||||
fastapi, and pydantic at module level.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# Stub core.config before batch imports it
|
||||
import types
|
||||
config_mod = types.ModuleType("core.config")
|
||||
config_mod.DATA_DIR = "/tmp/omnivoice_test_data"
|
||||
sys.modules["core.config"] = config_mod
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before the batch router imports the REAL core.config (the
|
||||
# old sys.modules stub leaked at collection time and broke mixed runs).
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from api.routers.batch import router, _jobs, _set_progress
|
||||
|
||||
@@ -4,17 +4,11 @@ Only tests the pure-Python helper functions (no GPU needed).
|
||||
The WebSocket endpoint itself requires the full app, which we
|
||||
skip in CI — it's integration-tested via the browser.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# Stub heavy deps
|
||||
import types
|
||||
for mod_name in ["services.model_manager", "services.asr_backend", "services.ffmpeg_utils"]:
|
||||
if mod_name not in sys.modules:
|
||||
sys.modules[mod_name] = types.ModuleType(mod_name)
|
||||
|
||||
# conftest.py puts `backend/` on sys.path. capture_ws imports its heavy deps
|
||||
# (model_manager / asr_backend / ffmpeg_utils) lazily inside handlers, so no
|
||||
# stubbing is needed — the old empty-ModuleType stubs leaked process-wide at
|
||||
# collection time and broke every later `from services.ffmpeg_utils import
|
||||
# find_ffmpeg` in mixed runs (see conftest.py).
|
||||
from api.routers.capture_ws import _chunks_to_wav, MIN_BUFFER_BYTES
|
||||
|
||||
|
||||
|
||||
@@ -9,23 +9,12 @@ are exercised at runtime.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="omnivoice_community_test_")
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = _TMP
|
||||
_config.VOICES_DIR = str(Path(_TMP) / "voices")
|
||||
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
|
||||
sys.modules["core.config"] = _config
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before this module imports the REAL core.config (the old
|
||||
# sys.modules stub leaked at collection time and broke mixed runs).
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Regression guard for the collection-time sys.modules stub class.
|
||||
|
||||
Seven modules in this directory used to install bare ``types.ModuleType``
|
||||
stubs for ``core.config`` (and test_capture_ws.py for ``services.*``) at
|
||||
module level. pytest imports every test module during *collection*, so the
|
||||
stubs leaked process-wide before a single test ran: in any mixed invocation
|
||||
(``pytest tests/... backend/tests/...``) later lazy imports resolved the
|
||||
stub — tests/test_router_smoke.py's ``from main import app`` died with
|
||||
``ImportError: cannot import name 'find_ffmpeg' from 'services.ffmpeg_utils'
|
||||
(unknown location)`` and tests/test_longform_e2e.py's
|
||||
``monkeypatch.setattr("core.config.OUTPUTS_DIR", ...)`` died with
|
||||
``AttributeError: module 'core' has no attribute 'config'``.
|
||||
|
||||
This test runs after collection has imported every sibling module, so any
|
||||
reintroduced module-level stub trips it even in a backend/tests-only run.
|
||||
A stub is recognizable because a bare ModuleType has neither ``__file__``
|
||||
(real module) nor ``__path__`` (namespace package).
|
||||
|
||||
If you need a fake module in a test, use ``monkeypatch.setitem(sys.modules,
|
||||
name, fake)`` inside the test — pytest restores it. For hermetic data dirs,
|
||||
rely on conftest.py's ``OMNIVOICE_DATA_DIR`` redirect instead of stubbing
|
||||
``core.config``.
|
||||
"""
|
||||
import sys
|
||||
|
||||
# Backend packages whose identity later tests depend on. Top-level third-party
|
||||
# modules are out of scope (some legitimately lack __file__, e.g. frozen ones).
|
||||
_GUARDED_PREFIXES = ("core.", "api.", "services.", "schemas.", "utils.")
|
||||
|
||||
|
||||
def test_no_collection_time_sys_modules_stubs():
|
||||
offenders = []
|
||||
for name, mod in list(sys.modules.items()):
|
||||
if mod is None or not name.startswith(_GUARDED_PREFIXES):
|
||||
continue
|
||||
if getattr(mod, "__file__", None) is None and not hasattr(mod, "__path__"):
|
||||
offenders.append(name)
|
||||
assert not offenders, (
|
||||
"Stub module(s) found in sys.modules: "
|
||||
f"{offenders}. Some test module installed a bare ModuleType at import "
|
||||
"time; that leaks process-wide from pytest collection and breaks "
|
||||
"every later import of the real module in mixed runs. Use "
|
||||
"monkeypatch.setitem(sys.modules, ...) inside the test instead."
|
||||
)
|
||||
@@ -6,34 +6,23 @@ the model. The export path's preview generation needs torchaudio and is covered
|
||||
by the service-layer round-trip in ``tests/test_persona_bundle.py`` + CI; here we
|
||||
only assert export's 404 (which fails before any audio work).
|
||||
|
||||
Follows the config-stub pattern of ``test_archetypes_api.py`` / ``test_community.py``
|
||||
so it mounts ONLY the persona router on a bare FastAPI app (no ``main`` import,
|
||||
no torch at collection).
|
||||
Follows the pattern of ``test_archetypes_api.py`` / ``test_community.py``:
|
||||
mounts ONLY the persona router on a bare FastAPI app (no ``main`` import,
|
||||
no torch at collection); conftest.py provides the hermetic data dir.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="omnivoice_personas_test_")
|
||||
_VOICES = Path(_TMP) / "voices"
|
||||
_VOICES.mkdir(parents=True, exist_ok=True)
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = _TMP
|
||||
_config.VOICES_DIR = str(_VOICES)
|
||||
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
|
||||
_config.DB_PATH = str(Path(_TMP) / "omnivoice.db")
|
||||
sys.modules["core.config"] = _config
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before this module imports the REAL core.config (the old
|
||||
# sys.modules stub leaked at collection time and broke mixed runs).
|
||||
from core import config as _config # noqa: E402
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
@@ -58,6 +58,10 @@ RUN uv pip install --system --no-cache .
|
||||
# Copy application source
|
||||
COPY backend/ ./backend/
|
||||
COPY omnivoice/ ./omnivoice/
|
||||
# Alembic config so schema migrations run natively on existing volumes
|
||||
# (without it the backend fell back to the additive-column self-heal —
|
||||
# functional, but the real migration chain is the first-class path).
|
||||
COPY alembic.ini ./
|
||||
|
||||
# Copy the pre-built React frontend from the builder stage
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
@@ -65,6 +69,12 @@ COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
# Expose the single unified API and UI port
|
||||
EXPOSE 3900
|
||||
|
||||
# Image-level health probe (compose files define their own; this covers plain
|
||||
# `docker run`). Generous start period: first boot creates the venv-less
|
||||
# schema + may pull model metadata before /health answers.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=5 \
|
||||
CMD curl -fsS http://127.0.0.1:3900/health || exit 1
|
||||
|
||||
# Mount points for persistent data (sqlite db, user voices, huggingface cache)
|
||||
VOLUME ["/app/omnivoice_data"]
|
||||
|
||||
|
||||
@@ -76,18 +76,33 @@ default** (set its var to `0` to disable); the rest default **off**.
|
||||
|
||||
## Restricted networks / mirrors (e.g. China)
|
||||
|
||||
If `huggingface.co` is slow or blocked, point the client at a mirror:
|
||||
**Automatic (the default).** When no endpoint is explicitly configured,
|
||||
OmniVoice picks one for you: it probes `huggingface.co` and the community
|
||||
mirror `hf-mirror.com` in parallel (short HTTPS reachability + latency
|
||||
checks — no geo-IP lookups, no third-party services; your device
|
||||
language/timezone only decides which endpoint is probed *first*), prefers the
|
||||
official endpoint unless the mirror is decisively faster, and remembers the
|
||||
winner. The decision is re-tested only on the first-run system check, after a
|
||||
network-classified download failure (the failed download retries once on the
|
||||
new winner), when it's more than 7 days old, or when you press **Test again**
|
||||
in **Settings → Models → Hugging Face mirror**. Mirror integrity is a
|
||||
non-issue: `huggingface_hub` verifies every download by checksum regardless
|
||||
of endpoint. Opt out with `OMNIVOICE_HF_ENDPOINT_MODE=manual`.
|
||||
|
||||
**Explicit (always wins).** To pin an endpoint yourself:
|
||||
|
||||
```
|
||||
HF_ENDPOINT=https://hf-mirror.com
|
||||
```
|
||||
|
||||
Set it in **Settings → Models → Hugging Face mirror** (quick-pick presets
|
||||
included), or as an environment variable before launching. On first run, the
|
||||
setup wizard offers the same mirror quick-pick right on the system-check
|
||||
screen when the endpoint is unreachable — the network check is a warning, not
|
||||
a blocker, so an offline or firewalled machine can still finish setup once
|
||||
models are available (mirror, or manual download below). Caveats:
|
||||
Set it in **Settings → Models → Hugging Face mirror** (quick-pick presets and
|
||||
a custom URL — any explicit choice switches the panel to manual mode and is
|
||||
**never** auto-switched), or as an environment variable before launching. On
|
||||
first run, the setup wizard's network check reports which endpoint the
|
||||
automatic selection picked, and still offers the mirror quick-pick when
|
||||
nothing is reachable — the check is a warning, not a blocker, so an offline
|
||||
or firewalled machine can still finish setup once models are available
|
||||
(mirror, or manual download below). Caveats:
|
||||
|
||||
- A mirror serves the **classic** download path, **not Xet** — you lose
|
||||
chunk-dedup and Xet's parallel fetch, but you gain reachability. On the
|
||||
|
||||
+72
-18
@@ -8,12 +8,57 @@ pins `transformers>=5.3`. This isolation is the resolution of
|
||||
canonical `OffloadedCache` ImportError that resulted from loading
|
||||
both libraries inside one Python interpreter.
|
||||
|
||||
## Install
|
||||
## Install (one-click, recommended)
|
||||
|
||||
IndexTTS-2 is **not** bundled with OmniVoice — the model weights are
|
||||
~6 GB and the package itself pins a conflicting transformers
|
||||
version. OmniVoice ships with a sidecar runner that loads IndexTTS
|
||||
into an isolated venv on demand.
|
||||
into an isolated venv on demand, plus a guided installer that
|
||||
provisions everything for you:
|
||||
|
||||
1. Open **Settings → Engines**, expand the IndexTTS2 row
|
||||
("Why unavailable?"), and click **Install**.
|
||||
2. Watch the step-by-step progress: preflight (uv + disk space),
|
||||
source fetch, isolated venv creation, dependency install,
|
||||
verification, model-weight download (~6 GB, resumable), and
|
||||
configuration save.
|
||||
3. Done — the engine flips to `available: true` immediately, **no
|
||||
restart needed**.
|
||||
|
||||
What the installer does under the hood (all cross-platform —
|
||||
macOS / Windows / Linux):
|
||||
|
||||
* Fetches the IndexTTS source with `git clone --depth 1` (or, when
|
||||
git isn't installed, downloads the GitHub source tarball over
|
||||
HTTPS) into OmniVoice's data directory
|
||||
(`<data-dir>/engines/indextts2/index-tts`).
|
||||
* Creates a dedicated venv inside the checkout with `uv venv` and
|
||||
runs `uv pip install -e .` against it — the `transformers<5`
|
||||
isolation is preserved; the parent app's environment is never
|
||||
touched. uv is resolved from `OMNIVOICE_BUNDLED_UV`, then `PATH`.
|
||||
* Downloads the `IndexTeam/IndexTTS-2` weights into
|
||||
`checkpoints/` (where the sidecar loads them from), honouring your
|
||||
configured/auto-selected Hugging Face endpoint and HF token.
|
||||
* Persists `OMNIVOICE_INDEXTTS_DIR` for you (in-process for
|
||||
immediate use + `prefs.json` for the next launch).
|
||||
|
||||
Preflight requires roughly **12 GB free disk space** (source + venv +
|
||||
weights, checked before anything is written); the install fails early
|
||||
with an actionable message otherwise. Re-running the installer is
|
||||
always safe: it repairs partial installs and resumes interrupted
|
||||
downloads instead of starting over. An app-managed install can be
|
||||
removed again with `DELETE /engines/sidecar/indextts2/install` (a
|
||||
user-managed clone is never touched).
|
||||
|
||||
If you already installed IndexTTS manually (any OmniVoice version),
|
||||
the installer detects it via `OMNIVOICE_INDEXTTS_DIR` and reports
|
||||
`already_installed` — nothing is re-downloaded or moved.
|
||||
|
||||
## Manual install (fallback)
|
||||
|
||||
The manual flow still works and is what the installer automates. Use
|
||||
it if you want the clone somewhere specific, share one clone across
|
||||
tools, or can't use the in-app installer:
|
||||
|
||||
1. Clone the IndexTTS repo on disk:
|
||||
|
||||
@@ -32,16 +77,12 @@ into an isolated venv on demand.
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
3. Download the model weights (~6 GB). Either:
|
||||
3. Download the model weights (~6 GB):
|
||||
|
||||
```bash
|
||||
hf download IndexTeam/IndexTTS-2 --local-dir=checkpoints
|
||||
```
|
||||
|
||||
or let HuggingFace cache them on first synthesize call (the parent
|
||||
forwards `HF_HOME` / `HF_HUB_CACHE` to the sidecar so the cache is
|
||||
shared with the rest of OmniVoice's downloads).
|
||||
|
||||
4. Set the `OMNIVOICE_INDEXTTS_DIR` environment variable to the repo
|
||||
root (the directory that contains `checkpoints/` and
|
||||
`pyproject.toml`):
|
||||
@@ -65,12 +106,14 @@ into an isolated venv on demand.
|
||||
OmniVoice probes for a usable IndexTTS Python interpreter in this
|
||||
priority order (see `backend/engines/indextts/bootstrap.py`):
|
||||
|
||||
1. **`${OMNIVOICE_INDEXTTS_DIR}/.venv/`** — your existing clone's
|
||||
venv. Highest priority, so v0.2.7 users who already ran
|
||||
`uv pip install -e .` get zero migration cost on the upgrade to
|
||||
v0.3.x.
|
||||
1. **`${OMNIVOICE_INDEXTTS_DIR}/.venv/`** — the install dir's own
|
||||
venv. This is what BOTH the one-click installer (which sets
|
||||
`OMNIVOICE_INDEXTTS_DIR` to its managed checkout) and a manual
|
||||
clone resolve to. Highest priority, so v0.2.7 users who already
|
||||
ran `uv pip install -e .` get zero migration cost on the upgrade
|
||||
to v0.3.x.
|
||||
2. **`backend/engines/indextts/.venv/`** — OmniVoice's own venv,
|
||||
created on demand by step 3.
|
||||
created on demand by the lazy bootstrap below.
|
||||
3. **Lazy bootstrap** — if neither venv exists, OmniVoice runs
|
||||
`uv venv backend/engines/indextts/.venv` and
|
||||
`uv pip install --python <python> -e ${OMNIVOICE_INDEXTTS_DIR}`
|
||||
@@ -87,14 +130,25 @@ weights survive the upgrade byte-for-byte.
|
||||
|
||||
### `IndexTTS-2 venv not found. Set OMNIVOICE_INDEXTTS_DIR ...`
|
||||
|
||||
You haven't pointed OmniVoice at an IndexTTS clone yet. Follow the
|
||||
**Install** steps above.
|
||||
You haven't installed IndexTTS yet. Click **Install** on the
|
||||
IndexTTS2 row in **Settings → Engines** (recommended), or follow the
|
||||
**Manual install** steps above.
|
||||
|
||||
### `uv is required to bootstrap the IndexTTS-2 venv but was not found on PATH`
|
||||
### `uv was not found` / `uv is required to bootstrap the IndexTTS-2 venv but was not found on PATH`
|
||||
|
||||
The bootstrap path needs a working `uv` binary. Either install `uv`
|
||||
into your `PATH` (https://docs.astral.sh/uv/) or pre-create the venv
|
||||
manually with `uv venv` and `uv pip install -e` as in step 2.
|
||||
Both the one-click installer and the bootstrap path need a working
|
||||
`uv` binary (resolved from `OMNIVOICE_BUNDLED_UV`, then `PATH`).
|
||||
Either install `uv` into your `PATH` (https://docs.astral.sh/uv/) or
|
||||
pre-create the venv manually with `uv venv` and `uv pip install -e`
|
||||
as in the manual steps.
|
||||
|
||||
### `Not enough disk space to install IndexTTS-2 ...`
|
||||
|
||||
The installer's preflight found less free space than the estimated
|
||||
source + venv + weights footprint (plus headroom). The message names
|
||||
the exact numbers; free up space (or move OmniVoice's data directory
|
||||
to a larger volume) and click Install again — it resumes where it
|
||||
stopped.
|
||||
|
||||
### `IndexTTS bootstrap completed but `import indextts.infer_v2` still fails`
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palash
|
||||
> |-----|--------------|
|
||||
> | `:latest` | **Rolling preview** — latest commit on `main` (always one patch ahead of the last release). This is the preview channel; pin `:stable` for production. |
|
||||
> | `:stable` | Most recent versioned release (updated on every `v*` git tag) |
|
||||
> | `:0.3.6` | Exact release version |
|
||||
> | `:0.3.17` | Exact release version |
|
||||
> | `:0.3` | Latest patch within the 0.3 minor |
|
||||
> | `:main` | Alias of the same rolling `main` build as `:latest` |
|
||||
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
|
||||
|
||||
@@ -12,13 +12,11 @@ working OmniVoice Studio install on a Debian / Ubuntu / Fedora / Arch host.
|
||||
- **~10 GB free disk** for the app, its Python environment, and model weights.
|
||||
- Optional: an **NVIDIA driver** for CUDA GPU acceleration — the app runs
|
||||
CPU-only without one. For AMD GPUs see [AMD GPU (ROCm)](#amd-gpu-rocm).
|
||||
- Optional: **yt-dlp** for downloading YouTube/video clips directly in the
|
||||
Voice Gallery and Dub tabs — `sudo apt install yt-dlp` (Debian/Ubuntu),
|
||||
`sudo dnf install yt-dlp` (Fedora), or `sudo pacman -S yt-dlp` (Arch).
|
||||
Without it those downloads fail; everything else works fine.
|
||||
|
||||
That's it — Python, FFmpeg, and the model weights are bundled or bootstrapped
|
||||
by the app itself on first launch. No toolchain needed.
|
||||
That's it — Python, FFmpeg/FFprobe, yt-dlp, and the model weights are bundled
|
||||
or bootstrapped by the app itself on first launch. No toolchain needed. (If no
|
||||
FFmpeg resolves anywhere, the app downloads its own checksummed static build
|
||||
in the background during setup; **Settings → Audio tools** shows exactly which
|
||||
binaries are in use and lets you override them or update yt-dlp.)
|
||||
|
||||
### Building from source
|
||||
|
||||
@@ -29,7 +27,6 @@ Everything above, plus the toolchain:
|
||||
- **Python 3.11+** — typically `sudo apt install python3.11` on Debian/Ubuntu,
|
||||
`sudo dnf install python3.11` on Fedora, or already installed on Arch.
|
||||
- **Bun** — `curl -fsSL https://bun.sh/install | bash`.
|
||||
- **FFmpeg** — `sudo apt install ffmpeg` (Debian/Ubuntu), `sudo dnf install ffmpeg-free` (Fedora), or `sudo pacman -S ffmpeg` (Arch).
|
||||
- **Rust / Cargo** — `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or via your package manager (e.g., `sudo apt install rustc cargo`).
|
||||
If you use rustup, reopen the shell or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
|
||||
- **GTK/WebKit deps** for the Tauri shell:
|
||||
|
||||
@@ -35,10 +35,15 @@ Everything above, plus the toolchain:
|
||||
and the C toolchain; `curl` ships with macOS).
|
||||
- **Python 3.11+** — `brew install python@3.11` (or use `pyenv` / the system Python if you already have ≥3.11).
|
||||
- **Bun** — `curl -fsSL https://bun.sh/install | bash`.
|
||||
- **FFmpeg** (used by the dubbing + capture pipelines) — `brew install ffmpeg`.
|
||||
- **Rust / Cargo** — `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or `brew install rust`.
|
||||
If you use rustup, reopen the terminal or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
|
||||
|
||||
FFmpeg/FFprobe and yt-dlp are **not** prerequisites on any install path: the
|
||||
app resolves them itself (a static build ships with the Python environment;
|
||||
if nothing resolves, the app downloads its own checksummed build on first
|
||||
run). Power users can inspect or override the binaries in
|
||||
**Settings → Audio tools** — including pointing at a Homebrew copy.
|
||||
|
||||
Optional but recommended:
|
||||
|
||||
- **A Hugging Face account** for diarization and the larger TTS models. See
|
||||
|
||||
@@ -167,6 +167,32 @@ that rely on `/usr/bin/ffprobe`.
|
||||
|
||||
**Fix:** see [linux.md#deb-ffprobe-conflict](linux.md#deb-ffprobe-conflict).
|
||||
|
||||
## 7b. "Media engine unavailable" / FFmpeg questions
|
||||
|
||||
FFmpeg, FFprobe, and yt-dlp are **not** things you install for OmniVoice.
|
||||
The app resolves them itself, in order: a path provided by the desktop shell →
|
||||
the static build shipped with the Python environment → the app's own
|
||||
downloaded build → whatever is on your PATH. When nothing resolves at all
|
||||
(some source installs on a fresh machine), the Setup Wizard downloads a
|
||||
pinned, checksum-verified static build in the background — you'll see a
|
||||
one-line "Preparing media engine…" progress and, only if that download fails,
|
||||
a card with **Retry** and **Use a system copy**.
|
||||
|
||||
If a running install ever reports "Media engine unavailable":
|
||||
|
||||
1. Open **Settings → Audio tools**. Each row shows the binary actually in use
|
||||
(version, path, and origin — Bundled / System / Custom).
|
||||
2. Press **Restore bundled** to re-fetch the app's own build (needs network
|
||||
once), or **Use system copy** / **Choose file…** to point at an FFmpeg you
|
||||
already have. Installing via a package manager (`brew install ffmpeg`,
|
||||
`sudo apt install ffmpeg`, `winget install ffmpeg`) also works — press
|
||||
**Use system copy** afterwards.
|
||||
|
||||
The same panel updates **yt-dlp** (video imports): site support changes
|
||||
faster than app releases, so when video-URL imports start failing, press
|
||||
**Update** there — the new version survives app updates, and **Restore tested
|
||||
version** reverts to the build the app shipped with.
|
||||
|
||||
## 8. Docker LAN access — media preview 404
|
||||
|
||||
**Symptom:** OmniVoice loads on `http://<lan-ip>:3900` but the audio preview
|
||||
@@ -323,10 +349,16 @@ order:
|
||||
files are a common false-positive quarantine), then re-enable.
|
||||
- **Connection** — use a stable, direct connection; pause any VPN; avoid
|
||||
corporate/school networks.
|
||||
- **Region mirror** — if `huggingface.co` is slow/blocked where you are, pick a
|
||||
mirror in-app (**Settings → Models → Hugging Face mirror**, or the quick-pick
|
||||
the first-run system check offers when the endpoint is unreachable), or set
|
||||
it as an env var before launching and relaunch:
|
||||
- **Region mirror** — if `huggingface.co` is slow/blocked where you are,
|
||||
OmniVoice normally handles this automatically: with no endpoint explicitly
|
||||
configured it probes both the official endpoint and the `hf-mirror.com`
|
||||
community mirror and downloads from whichever works (downloads are
|
||||
checksum-verified either way; see
|
||||
[downloading-models.md](../downloading-models.md)). To check or re-test the
|
||||
automatic pick, use **Settings → Models → Hugging Face mirror → Test
|
||||
again**. To pin a mirror yourself, pick one in the same panel (or the
|
||||
quick-pick the first-run system check offers when nothing is reachable), or
|
||||
set it as an env var before launching and relaunch:
|
||||
- macOS/Linux: `export HF_ENDPOINT=https://hf-mirror.com`
|
||||
- Windows (PowerShell): `[Environment]::SetEnvironmentVariable("HF_ENDPOINT","https://hf-mirror.com","User")`
|
||||
|
||||
|
||||
+4
-1
@@ -76,7 +76,10 @@ Settings → Sharing → **Remote backend**:
|
||||
- **Test connection** hits `{url}/health` and shows the remote's version and
|
||||
device.
|
||||
- **Save & reload** stores both in this browser/app and restarts the UI
|
||||
against the remote.
|
||||
against the remote. The URL must be a full `http://` or `https://` URL
|
||||
(`gpu-box:3900` alone is rejected), and saving a URL that hasn't passed
|
||||
**Test connection** asks for confirmation first — a wrong base would leave
|
||||
the app unable to reach any backend until you change it back here.
|
||||
|
||||
Leave the URL empty to go back to the local backend.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.16",
|
||||
"version": "0.3.18",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.16"
|
||||
version = "0.3.18"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.16"
|
||||
version = "0.3.18"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -83,7 +83,41 @@ pub fn same_app_version(running: &str) -> bool {
|
||||
!running.is_empty() && base(running) == base(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Deep health probe for the attach-to-a-running-backend shortcut.
|
||||
///
|
||||
/// `/health` and `/system/info` keep answering from a backend whose install
|
||||
/// was deleted out from under it (files unlinked on disk, code already in
|
||||
/// memory) — that zombie passes the version check and then 500s every real
|
||||
/// route, so the UI looks alive but nothing works. Probe a DB-touching
|
||||
/// endpoint and require an actual `200` status line before attaching;
|
||||
/// anything else (500, timeout, refused) means the responder is not a
|
||||
/// backend worth keeping.
|
||||
pub fn backend_deep_healthy(port: u16) -> bool {
|
||||
let url = format!("http://127.0.0.1:{}/profiles", port);
|
||||
match raw_http_get(&url, Duration::from_millis(1500)) {
|
||||
Ok(resp) => parse_http_status(&resp) == Some(200),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Status code from a raw HTTP response ("HTTP/1.1 200 OK" → 200).
|
||||
fn parse_http_status(response: &str) -> Option<u16> {
|
||||
let line = response.lines().next()?;
|
||||
line.split_whitespace().nth(1)?.parse().ok()
|
||||
}
|
||||
|
||||
fn ureq_get_with_timeout(url: &str, timeout: Duration) -> Result<String, String> {
|
||||
let buf = raw_http_get(url, timeout)?;
|
||||
if let Some(idx) = buf.find("\r\n\r\n") {
|
||||
Ok(buf[idx + 4..].to_string())
|
||||
} else {
|
||||
Err("no body".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// One raw loopback HTTP GET, returning the FULL response (status line +
|
||||
/// headers + body). Kept dependency-free on purpose — see module docs.
|
||||
fn raw_http_get(url: &str, timeout: Duration) -> Result<String, String> {
|
||||
let url = url.strip_prefix("http://").ok_or("only http:// supported")?;
|
||||
let (host_port, path) = match url.find('/') {
|
||||
Some(i) => (&url[..i], &url[i..]),
|
||||
@@ -112,11 +146,7 @@ fn ureq_get_with_timeout(url: &str, timeout: Duration) -> Result<String, String>
|
||||
stream.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
|
||||
let mut buf = String::new();
|
||||
stream.read_to_string(&mut buf).map_err(|e| e.to_string())?;
|
||||
if let Some(idx) = buf.find("\r\n\r\n") {
|
||||
Ok(buf[idx + 4..].to_string())
|
||||
} else {
|
||||
Err("no body".into())
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Kill whatever process owns the port.
|
||||
@@ -428,6 +458,17 @@ mod tests {
|
||||
assert_eq!(parse_app_version("<html>not json</html>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_status_reads_the_status_line_only() {
|
||||
assert_eq!(super::parse_http_status("HTTP/1.1 200 OK\r\nX: 500\r\n\r\nbody"), Some(200));
|
||||
assert_eq!(
|
||||
super::parse_http_status("HTTP/1.1 500 Internal Server Error\r\n\r\nInternal Server Error"),
|
||||
Some(500)
|
||||
);
|
||||
assert_eq!(super::parse_http_status("garbage"), None);
|
||||
assert_eq!(super::parse_http_status(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_app_version_matches_current_build_and_rejects_stale() {
|
||||
let ours = env!("CARGO_PKG_VERSION");
|
||||
|
||||
@@ -148,12 +148,24 @@ pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapS
|
||||
}
|
||||
match crate::backend::running_backend_version(backend_port()) {
|
||||
Some(v) if crate::backend::same_app_version(&v) => {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend v{} — attaching",
|
||||
if crate::backend::backend_deep_healthy(backend_port()) {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend v{} — attaching",
|
||||
backend_port(), v
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
// Same version but a DB-touching probe fails: a backend whose
|
||||
// install was wiped/corrupted while it kept running. Attaching
|
||||
// would look alive and 500 on everything — replace it.
|
||||
log::warn!(
|
||||
"Port {} serves OmniVoice v{} but failed the deep health probe — replacing it",
|
||||
backend_port(), v
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
set_backend_kill_intended(true); // deliberate kill, not a crash (#941)
|
||||
crate::backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
Some(v) => {
|
||||
// A healthy-but-stale backend from a previous version (the
|
||||
|
||||
@@ -807,12 +807,23 @@ pub fn run() {
|
||||
}
|
||||
match backend::running_backend_version(backend_port()) {
|
||||
Some(v) if backend::same_app_version(&v) => {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend v{} — attaching",
|
||||
if backend::backend_deep_healthy(backend_port()) {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend v{} — attaching",
|
||||
backend_port(), v
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
// Same version but a DB-touching probe fails: a backend whose
|
||||
// install was wiped/corrupted while it kept running. Attaching
|
||||
// would look alive and 500 on everything — replace it.
|
||||
log::warn!(
|
||||
"Port {} serves OmniVoice v{} but failed the deep health probe — replacing it",
|
||||
backend_port(), v
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
Some(v) => {
|
||||
// Healthy-but-stale backend from a previous version —
|
||||
|
||||
@@ -84,6 +84,64 @@ export async function selfTestEngine(engineId: string): Promise<EngineSelfTestRe
|
||||
return apiPost<EngineSelfTestResponse>(`/engines/${encodeURIComponent(engineId)}/selftest`, {});
|
||||
}
|
||||
|
||||
// ── One-click sidecar-engine install (IndexTTS-2 & friends) ─────────────
|
||||
|
||||
export type SidecarStepState = 'pending' | 'running' | 'done' | 'skipped' | 'error';
|
||||
|
||||
export interface SidecarInstallStep {
|
||||
id: string;
|
||||
state: SidecarStepState;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface SidecarInstallJob {
|
||||
engine_id: string;
|
||||
state: 'running' | 'succeeded' | 'failed';
|
||||
steps: SidecarInstallStep[];
|
||||
log: string[];
|
||||
error: string | null;
|
||||
remediation: string | null;
|
||||
weights_progress: {
|
||||
filename: string | null;
|
||||
downloaded: number | null;
|
||||
total: number | null;
|
||||
pct: number | null;
|
||||
} | null;
|
||||
started_at: number;
|
||||
finished_at: number | null;
|
||||
}
|
||||
|
||||
export interface SidecarInstallStatus {
|
||||
engine_id: string;
|
||||
installed: boolean;
|
||||
managed: boolean;
|
||||
install_dir: string | null;
|
||||
job: SidecarInstallJob | null;
|
||||
}
|
||||
|
||||
export interface SidecarInstallStartResponse {
|
||||
status: 'started' | 'already_running' | 'already_installed';
|
||||
engine: string;
|
||||
}
|
||||
|
||||
/** Start the resumable one-click install for a sidecar engine (IndexTTS-2).
|
||||
* Idempotent: re-POSTing while a job runs returns `already_running`; a
|
||||
* healthy install returns `already_installed`; a partial install repairs. */
|
||||
export async function installSidecarEngine(engineId: string): Promise<SidecarInstallStartResponse> {
|
||||
return apiPost<SidecarInstallStartResponse>(
|
||||
`/engines/sidecar/${encodeURIComponent(engineId)}/install`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/** Poll the sidecar install job — step-by-step states + log tail + error
|
||||
* with remediation. Cheap (file probes only), safe to poll every ~1.5 s. */
|
||||
export async function getSidecarInstallStatus(engineId: string): Promise<SidecarInstallStatus> {
|
||||
return apiJson<SidecarInstallStatus>(
|
||||
`/engines/sidecar/${encodeURIComponent(engineId)}/install/status`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listTranslationEngines(): Promise<TranslationEnginesResponse> {
|
||||
return apiJson<TranslationEnginesResponse>('/engines/translation');
|
||||
}
|
||||
|
||||
@@ -88,6 +88,38 @@ export async function modelStatus(): Promise<ModelStatus> {
|
||||
return apiJson<ModelStatus>('/model/status');
|
||||
}
|
||||
|
||||
// ── Loaded-model residency (MM2-04 endpoints) ────────────────────────────
|
||||
|
||||
/** One entry from GET /model/loaded — a model currently resident in memory.
|
||||
* `engine_id`/`is_active_engine` attribute TTS-family entries to an engine
|
||||
* (a model can stay resident after the user switches engines). */
|
||||
export interface LoadedModel {
|
||||
id: string; // 'tts' | 'asr' | 'diarization' | 'sidecar:<engine>'
|
||||
name: string;
|
||||
checkpoint: string;
|
||||
device: string;
|
||||
vram_mb: number;
|
||||
unloadable: boolean;
|
||||
note?: string;
|
||||
engine_id?: string;
|
||||
is_active_engine?: boolean | null;
|
||||
}
|
||||
|
||||
export interface LoadedModelsResponse {
|
||||
models: LoadedModel[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function listLoadedModels(): Promise<LoadedModelsResponse> {
|
||||
return apiJson<LoadedModelsResponse>('/model/loaded');
|
||||
}
|
||||
|
||||
/** Unload one resident model by its /model/loaded `id`. The model reloads
|
||||
* lazily on next use — unloading only frees memory, it never loses data. */
|
||||
export async function unloadLoadedModel(modelId: string): Promise<unknown> {
|
||||
return apiPost(`/model/unload/${encodeURIComponent(modelId)}`);
|
||||
}
|
||||
|
||||
// ── Audio cleaning ───────────────────────────────────────────────────────
|
||||
|
||||
export async function cleanAudio(formData: FormData): Promise<Response> {
|
||||
|
||||
@@ -29,10 +29,22 @@ interface EngineBackend {
|
||||
display_name: string;
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
// Available-but-has-advice: the backend's `is_available()` returned ok with
|
||||
// an advisory tail ("ready — <advice>", e.g. VoxCPM2's upgrade hint). Null
|
||||
// for plain-ready and unavailable rows; absent on legacy payloads.
|
||||
hint?: string | null;
|
||||
// Cloning capability (TTS family): true/false from the backend class, null
|
||||
// when model-dependent (mlx-audio's curated models differ). Only badge on
|
||||
// an explicit true.
|
||||
supports_cloning?: boolean | null;
|
||||
install_hint?: string | null;
|
||||
// Copy-paste-ready `export VAR=...` line for a path-gated opt-in engine
|
||||
// (IndexTTS / MOSS-v1.5 / dots.tts / Confucius4), else null/absent.
|
||||
setup_snippet?: string | null;
|
||||
// True when the backend's sidecar provisioner can install this engine
|
||||
// in-app (Settings renders an Install button; the manual snippet is
|
||||
// demoted to a collapsible fallback). Absent on legacy payloads.
|
||||
one_click_install?: boolean;
|
||||
last_error?: string | null;
|
||||
isolation_mode?: 'in-process' | 'subprocess';
|
||||
gpu_compat?: GPUTarget[];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* EngineMark — the per-engine identity mark for the Models & Engines
|
||||
* Settings surfaces.
|
||||
*
|
||||
* A small monogram chip whose hue is derived deterministically from the
|
||||
* engine id (the same trick as `models/format.js`'s `orgColor` for HF
|
||||
* orgs), so the same engine is instantly recognizable everywhere it
|
||||
* appears on these pages: the Engine Compatibility Matrix rows and the
|
||||
* "in memory" residency chips. Purely decorative (`aria-hidden`) — the
|
||||
* engine's name and id are always rendered as text alongside it.
|
||||
*
|
||||
* Theme-safe by construction: the hue is fixed per engine, but the fill
|
||||
* is a low-opacity `color-mix` over transparent and the glyph color is
|
||||
* mixed toward `--chrome-fg`, so it stays legible on light and dark
|
||||
* themes without per-theme overrides.
|
||||
*/
|
||||
|
||||
/** Deterministic hue (0–359) from an engine id. */
|
||||
export function engineHue(id) {
|
||||
const s = String(id || '');
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
/** Two-character monogram from an engine id ("mlx-audio" → "MA",
|
||||
* "voxcpm2" → "VO"). Falls back to "?" for an empty id. */
|
||||
export function engineMonogram(id) {
|
||||
const parts = String(id || '')
|
||||
.split(/[^a-z0-9]+/i)
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
const mono = parts.length >= 2 ? parts[0][0] + parts[1][0] : parts[0].slice(0, 2);
|
||||
return mono.toUpperCase();
|
||||
}
|
||||
|
||||
export default function EngineMark({ id, size = 20, className = '' }) {
|
||||
const accent = `hsl(${engineHue(id)} 62% 52%)`;
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid={`engine-mark-${id}`}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 select-none items-center justify-center rounded-[5px] font-semibold tracking-[0.02em]',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: Math.max(8, Math.round(size * 0.42)),
|
||||
background: `color-mix(in srgb, ${accent} 15%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${accent} 40%, transparent)`,
|
||||
color: `color-mix(in srgb, ${accent} 55%, var(--chrome-fg, currentColor))`,
|
||||
}}
|
||||
>
|
||||
{engineMonogram(id)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -798,6 +798,9 @@ export default function LogsFooter() {
|
||||
if (notif.action.type === 'navigate') {
|
||||
useAppStore.getState().setMode?.(notif.action.target);
|
||||
setCollapsed(true);
|
||||
} else if (notif.action.type === 'settings-tab') {
|
||||
useAppStore.getState().openSettingsTab?.(notif.action.target);
|
||||
setCollapsed(true);
|
||||
} else if (notif.action.type === 'link') {
|
||||
import('../api/external').then((m) => m.openExternal(notif.action.target));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Media engine — invisible unless it needs help.
|
||||
*
|
||||
* The media engine (ffmpeg/ffprobe) is an internal dependency, not a system
|
||||
* requirement: when the backend's resolution chain finds nothing, preflight
|
||||
* already kicked a background download of the app's own pinned static build.
|
||||
* This renders NOTHING when the engine is ready (the ideal outcome), a quiet
|
||||
* one-line progress while acquiring, and an actionable card only on failure
|
||||
* (Retry / use a copy already on the machine). yt-dlp never appears here —
|
||||
* it's an importable module, not a user task.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { apiJson, apiFetch } from '../api/client';
|
||||
import { Button } from '../ui';
|
||||
|
||||
export default function MediaEngineCard() {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [detectError, setDetectError] = useState(null);
|
||||
const [customPath, setCustomPath] = useState('');
|
||||
const [showPathInput, setShowPathInput] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const st = await apiJson('/media-tools/status');
|
||||
setStatus(st);
|
||||
return st;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const acquiring = status?.ops?.acquire?.state === 'running';
|
||||
useEffect(() => {
|
||||
if (!acquiring) return undefined;
|
||||
const iv = setInterval(refresh, 1500);
|
||||
return () => clearInterval(iv);
|
||||
}, [acquiring, refresh]);
|
||||
|
||||
const post = async (path, body) => {
|
||||
setBusy(true);
|
||||
setDetectError(null);
|
||||
try {
|
||||
const res = await apiFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
detail = (await res.json())?.detail || detail;
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
setDetectError(e?.message || String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const useSystemCopy = async () => {
|
||||
// ffprobe rides along: the resolver derives the sibling ffprobe from a
|
||||
// resolved ffmpeg, so pinning ffmpeg is enough in the common case.
|
||||
await post('/media-tools/ffmpeg/use-system');
|
||||
};
|
||||
|
||||
const chooseFile = async () => {
|
||||
try {
|
||||
if ('__TAURI_INTERNALS__' in window) {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const picked = await open({ multiple: false, directory: false, title: 'FFmpeg' });
|
||||
if (typeof picked === 'string') {
|
||||
await post('/media-tools/ffmpeg/custom-path', { path: picked });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* picker unavailable — fall through to the inline input */
|
||||
}
|
||||
setShowPathInput(true);
|
||||
};
|
||||
|
||||
if (!status || status.ready) return null; // the ideal outcome: nothing.
|
||||
|
||||
const op = status.ops?.acquire || {};
|
||||
if (op.state === 'running' || op.state === 'idle') {
|
||||
// idle-and-not-ready = preflight is about to kick the download (or a
|
||||
// recheck is in flight) — show the quiet line, never flash the card.
|
||||
return (
|
||||
<div
|
||||
className="mt-3 flex items-center gap-2 text-xs text-fg-muted"
|
||||
data-testid="media-engine-progress"
|
||||
>
|
||||
<Loader className="animate-spin" size={12} aria-hidden="true" />
|
||||
{t('setup.media_engine_preparing', { defaultValue: 'Preparing media engine…' })}
|
||||
{op.state === 'running' && ` ${Math.round((op.progress || 0) * 100)}%`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-3 flex flex-col gap-1.5 rounded-md border border-border px-3 py-2.5"
|
||||
data-testid="media-engine-card"
|
||||
>
|
||||
<span className="text-sm font-semibold">
|
||||
{t('setup.media_engine_failed_title', { defaultValue: 'Media engine download failed' })}
|
||||
</span>
|
||||
<span className="text-xs leading-snug text-fg-muted">
|
||||
{t('setup.media_engine_failed_desc', {
|
||||
defaultValue:
|
||||
"The app couldn't fetch its bundled audio/video engine (FFmpeg). Retry, or point it at a copy already on this computer.",
|
||||
})}
|
||||
</span>
|
||||
{(op.error || detectError) && (
|
||||
<span className="text-xs text-danger" role="alert" data-testid="media-engine-error">
|
||||
{detectError || op.error}
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
onClick={() => post('/media-tools/acquire')}
|
||||
data-testid="media-engine-retry"
|
||||
>
|
||||
{t('setup.media_engine_retry', { defaultValue: 'Retry' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={useSystemCopy}
|
||||
data-testid="media-engine-use-system"
|
||||
>
|
||||
{t('setup.media_engine_use_system', { defaultValue: 'Use a system copy' })}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={chooseFile}>
|
||||
{t('setup.media_engine_choose_file', { defaultValue: 'Choose file…' })}
|
||||
</Button>
|
||||
{showPathInput && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
placeholder="/usr/bin/ffmpeg"
|
||||
className="min-w-[220px] flex-1 rounded border border-border bg-transparent px-2 py-1 font-mono text-xs text-fg"
|
||||
aria-label={t('settings.ffmpeg_input_aria', { defaultValue: 'FFmpeg path' })}
|
||||
data-testid="media-engine-path"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={busy || !customPath.trim()}
|
||||
onClick={() => post('/media-tools/ffmpeg/custom-path', { path: customPath.trim() })}
|
||||
>
|
||||
{t('credentials.save', { defaultValue: 'Save' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
apiJson: vi.fn(),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { apiJson, apiFetch } from '../api/client';
|
||||
import MediaEngineCard from './MediaEngineCard';
|
||||
|
||||
const statusWith = (ready, acquire) => ({
|
||||
ready,
|
||||
tools: {},
|
||||
ops: { acquire: acquire || { state: 'idle', progress: 0, error: null } },
|
||||
});
|
||||
|
||||
describe('MediaEngineCard — invisible-by-default media engine', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
it('renders NOTHING when the media engine is resolved (the ideal outcome)', async () => {
|
||||
apiJson.mockResolvedValue(statusWith(true));
|
||||
const { container } = render(<MediaEngineCard />);
|
||||
await waitFor(() => expect(apiJson).toHaveBeenCalledWith('/media-tools/status'));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByTestId('media-engine-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only a quiet progress line while the bundled build downloads', async () => {
|
||||
apiJson.mockResolvedValue(statusWith(false, { state: 'running', progress: 0.42, error: null }));
|
||||
render(<MediaEngineCard />);
|
||||
const line = await screen.findByTestId('media-engine-progress');
|
||||
expect(line).toHaveTextContent('Preparing media engine…');
|
||||
expect(line).toHaveTextContent('42%');
|
||||
// No requirements-style card, no mention of package managers.
|
||||
expect(screen.queryByTestId('media-engine-card')).not.toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toMatch(/brew|apt|choco/i);
|
||||
});
|
||||
|
||||
it('shows the actionable failure card only when acquisition failed', async () => {
|
||||
apiJson.mockResolvedValue(
|
||||
statusWith(false, { state: 'error', progress: 0, error: 'download checksum mismatch' }),
|
||||
);
|
||||
render(<MediaEngineCard />);
|
||||
const card = await screen.findByTestId('media-engine-card');
|
||||
expect(card).toHaveTextContent('Media engine download failed');
|
||||
expect(screen.getByTestId('media-engine-error')).toHaveTextContent(
|
||||
'download checksum mismatch',
|
||||
);
|
||||
expect(screen.getByTestId('media-engine-retry')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('media-engine-use-system')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Retry re-posts the acquisition endpoint', async () => {
|
||||
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
|
||||
render(<MediaEngineCard />);
|
||||
fireEvent.click(await screen.findByTestId('media-engine-retry'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith('/media-tools/acquire', expect.anything()),
|
||||
);
|
||||
});
|
||||
|
||||
it('Use a system copy posts use-system and surfaces a not-found detail', async () => {
|
||||
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({
|
||||
detail: 'No system ffmpeg found on PATH or in the usual install locations.',
|
||||
}),
|
||||
});
|
||||
render(<MediaEngineCard />);
|
||||
fireEvent.click(await screen.findByTestId('media-engine-use-system'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffmpeg/use-system', expect.anything()),
|
||||
);
|
||||
expect(await screen.findByTestId('media-engine-error')).toHaveTextContent(
|
||||
'No system ffmpeg found',
|
||||
);
|
||||
});
|
||||
|
||||
it('Choose file… falls back to an inline path input outside Tauri and saves it', async () => {
|
||||
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
|
||||
render(<MediaEngineCard />);
|
||||
fireEvent.click(await screen.findByText('Choose file…'));
|
||||
const input = await screen.findByTestId('media-engine-path');
|
||||
fireEvent.change(input, { target: { value: '/usr/local/bin/ffmpeg' } });
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
'/media-tools/ffmpeg/custom-path',
|
||||
expect.objectContaining({ body: JSON.stringify({ path: '/usr/local/bin/ffmpeg' }) }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,50 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { openExternal } from '../../api/external';
|
||||
import { resolveAboutVersion } from '../../utils/appVersion';
|
||||
import { REPO_URL } from '../../utils/bugReport';
|
||||
import { Button, Badge } from '../../ui';
|
||||
import { SettingsSection } from './primitives';
|
||||
import { CATEGORY_BY_ID } from './settingsCategories';
|
||||
import { useAppStore } from '../../store';
|
||||
import { isTauri } from './native';
|
||||
import Row from './Row';
|
||||
|
||||
/**
|
||||
* Where a failing self-check can be fixed inside the app — diagnose check id
|
||||
* (backend/core/diagnose.py) → Settings category id. Checks without an in-app
|
||||
* fix (python, backend, …) render their hint as plain text only.
|
||||
*/
|
||||
const CHECK_FIX_CATEGORY = {
|
||||
ffmpeg: 'network',
|
||||
hf_token: 'credentials',
|
||||
disk: 'storage',
|
||||
data_dir: 'storage',
|
||||
engines: 'engines',
|
||||
gpu_routing: 'engines',
|
||||
device: 'performance',
|
||||
ram: 'performance',
|
||||
deep_synth: 'logs',
|
||||
};
|
||||
|
||||
/** Small "Open <category>" deep-link into the Settings hub. */
|
||||
function OpenCategoryButton({ categoryId }) {
|
||||
const { t } = useTranslation();
|
||||
const cat = CATEGORY_BY_ID[categoryId];
|
||||
if (!cat) return null;
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => useAppStore.getState().openSettingsTab(categoryId)}
|
||||
>
|
||||
{t('about.open_fix_category', {
|
||||
defaultValue: 'Open {{category}}',
|
||||
category: t(cat.labelKey, { defaultValue: cat.defaultLabel }),
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → About.
|
||||
*
|
||||
@@ -52,7 +90,16 @@ export default function AboutTab({
|
||||
/>
|
||||
<Row
|
||||
label={t('about.hf_token')}
|
||||
value={info?.has_hf_token ? t('about.yes') : t('about.no')}
|
||||
value={
|
||||
info?.has_hf_token ? (
|
||||
t('about.yes')
|
||||
) : (
|
||||
<span className="inline-flex flex-wrap items-center gap-[var(--space-3)]">
|
||||
{t('about.no')}
|
||||
<OpenCategoryButton categoryId="credentials" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="settings-link-row mt-[var(--space-5)] flex flex-wrap gap-[var(--space-4)]">
|
||||
@@ -92,18 +139,10 @@ export default function AboutTab({
|
||||
variant="subtle"
|
||||
size="md"
|
||||
leading={<ExternalLink size={12} />}
|
||||
onClick={() => openExternal('https://github.com/k2-fsa/OmniVoice')}
|
||||
onClick={() => openExternal(REPO_URL)}
|
||||
>
|
||||
{t('about.github')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="md"
|
||||
leading={<ExternalLink size={12} />}
|
||||
onClick={() => openExternal('https://huggingface.co/k2-fsa/OmniVoice')}
|
||||
>
|
||||
{t('about.model_card')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="md"
|
||||
@@ -136,6 +175,12 @@ export default function AboutTab({
|
||||
— {c.hint}
|
||||
</span>
|
||||
)}
|
||||
{c.status !== 'ok' && CHECK_FIX_CATEGORY[c.id] && (
|
||||
<>
|
||||
{' '}
|
||||
<OpenCategoryButton categoryId={CHECK_FIX_CATEGORY[c.id]} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import AboutTab from './AboutTab';
|
||||
import { REPO_URL } from '../../utils/bugReport';
|
||||
import { openExternal } from '../../api/external';
|
||||
import { useAppStore } from '../../store';
|
||||
|
||||
vi.mock('../../api/external', () => ({ openExternal: vi.fn() }));
|
||||
|
||||
const noop = () => {};
|
||||
const baseProps = {
|
||||
appVersion: '0.0.0-test',
|
||||
tauriVersion: null,
|
||||
info: { has_hf_token: true },
|
||||
checkForUpdates: noop,
|
||||
updateState: 'idle',
|
||||
selfCheck: null,
|
||||
selfCheckRunning: false,
|
||||
runSelfCheck: noop,
|
||||
bundleBuilding: false,
|
||||
saveDiagnosticBundle: noop,
|
||||
copyDiagnostics: noop,
|
||||
};
|
||||
|
||||
describe('AboutTab — external links', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('the GitHub button opens the canonical repo (derived from the shared REPO_URL constant)', () => {
|
||||
render(<AboutTab {...baseProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'OmniVoice on GitHub' }));
|
||||
expect(openExternal).toHaveBeenCalledWith(REPO_URL);
|
||||
// Belt-and-braces: the constant itself must point at this project, not a
|
||||
// lookalike (the original bug linked github.com/k2-fsa/OmniVoice).
|
||||
expect(REPO_URL).toBe('https://github.com/debpalash/OmniVoice-Studio');
|
||||
});
|
||||
|
||||
it('has no "Model card" link — the app is multi-engine with no single model card', () => {
|
||||
render(<AboutTab {...baseProps} />);
|
||||
expect(screen.queryByRole('button', { name: /model card/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AboutTab — fixable problems deep-link into Settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppStore.getState().setMode('launchpad');
|
||||
useAppStore.getState().setPendingSettingsTab(null);
|
||||
});
|
||||
|
||||
it('HF token "no" offers an Open Credentials action instead of dead-ending', () => {
|
||||
render(<AboutTab {...baseProps} info={{ has_hf_token: false }} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Credentials' }));
|
||||
expect(useAppStore.getState().mode).toBe('settings');
|
||||
expect(useAppStore.getState().pendingSettingsTab).toBe('credentials');
|
||||
});
|
||||
|
||||
it('HF token "yes" renders no Credentials action', () => {
|
||||
render(<AboutTab {...baseProps} info={{ has_hf_token: true }} />);
|
||||
expect(screen.queryByRole('button', { name: 'Open Credentials' })).toBeNull();
|
||||
});
|
||||
|
||||
it('a failing self-check renders an "Open <category>" button for its fix destination', () => {
|
||||
const selfCheck = {
|
||||
checks: [
|
||||
{
|
||||
id: 'ffmpeg',
|
||||
label: 'ffmpeg',
|
||||
status: 'fail',
|
||||
detail: 'not found on PATH or FFMPEG_PATH',
|
||||
hint: 'Dubbing and audio conversion need ffmpeg.',
|
||||
},
|
||||
{ id: 'python', label: 'Python runtime', status: 'ok', detail: '3.12', hint: null },
|
||||
],
|
||||
summary: { ok: false, failures: 1 },
|
||||
};
|
||||
render(<AboutTab {...baseProps} selfCheck={selfCheck} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Network' }));
|
||||
expect(useAppStore.getState().mode).toBe('settings');
|
||||
expect(useAppStore.getState().pendingSettingsTab).toBe('network');
|
||||
});
|
||||
|
||||
it('passing checks render no deep-link button', () => {
|
||||
const selfCheck = {
|
||||
checks: [
|
||||
{ id: 'ffmpeg', label: 'ffmpeg', status: 'ok', detail: '/usr/bin/ffmpeg', hint: null },
|
||||
],
|
||||
summary: { ok: true, failures: 0 },
|
||||
};
|
||||
render(<AboutTab {...baseProps} selfCheck={selfCheck} />);
|
||||
expect(screen.queryByRole('button', { name: /^Open / })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,39 +7,34 @@
|
||||
* while OmniVoice plays audio doesn't transcribe the playback. Off by default —
|
||||
* dictation uses the standard MediaRecorder path and behaves identically on
|
||||
* every platform. The pref is the zustand `aecEnabled` flag (persisted); no
|
||||
* backend round-trip needed.
|
||||
* backend round-trip needed. All strings go through i18n (`dictation.aec_*`).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Volume2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../../store';
|
||||
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
|
||||
|
||||
export default function AecPanel() {
|
||||
const { t } = useTranslation();
|
||||
const aecEnabled = useAppStore((s) => s.aecEnabled);
|
||||
const setAecEnabled = useAppStore((s) => s.setAecEnabled);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Volume2}
|
||||
title="Dictate while audio plays"
|
||||
description="Cancel OmniVoice's own playback out of the microphone."
|
||||
title={t('dictation.aec_title')}
|
||||
description={t('dictation.aec_description')}
|
||||
>
|
||||
<SettingRow
|
||||
title="Enable echo cancellation for dictation"
|
||||
subtitle="experimental"
|
||||
hint={
|
||||
<>
|
||||
Cancels OmniVoice's own playback out of the microphone so you can dictate while a
|
||||
preview, dub, or video is playing — without the transcript picking up what the app is
|
||||
saying. Adds a small amount of audio processing; leave it off if you never dictate over
|
||||
playback.
|
||||
</>
|
||||
}
|
||||
title={t('dictation.aec_row_title')}
|
||||
subtitle={t('dictation.aec_experimental')}
|
||||
hint={t('dictation.aec_hint')}
|
||||
control={
|
||||
<SettingsToggle
|
||||
checked={aecEnabled}
|
||||
onChange={setAecEnabled}
|
||||
aria-label="Enable echo cancellation for dictation"
|
||||
aria-label={t('dictation.aec_row_title')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -25,15 +25,6 @@ import { CheckCircle2, KeyRound, RefreshCw, Save, Trash2, XCircle } from 'lucide
|
||||
import { apiJson, apiPost, apiFetch, API } from '../../api/client';
|
||||
import { SettingsSection, InfoHint } from './primitives';
|
||||
|
||||
const EMPTY_STATE = {
|
||||
sources: [
|
||||
{ source: 'app', set: false, masked: null, whoami_user: null, whoami_ok: false },
|
||||
{ source: 'env', set: false, masked: null, whoami_user: null, whoami_ok: false },
|
||||
{ source: 'hf-cli', set: false, masked: null, whoami_user: null, whoami_ok: false },
|
||||
],
|
||||
active: null,
|
||||
};
|
||||
|
||||
export default function ApiKeysPanel() {
|
||||
const { t } = useTranslation();
|
||||
const SOURCE_LABELS = {
|
||||
@@ -52,7 +43,9 @@ export default function ApiKeysPanel() {
|
||||
defaultValue: 'Written by `huggingface-cli login`. Read-only from the UI.',
|
||||
}),
|
||||
};
|
||||
const [state, setState] = useState(EMPTY_STATE);
|
||||
// null until the first GET lands — the panel renders a "checking" placeholder
|
||||
// instead of flashing a false amber "not set" verdict for every source.
|
||||
const [state, setState] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -60,27 +53,36 @@ export default function ApiKeysPanel() {
|
||||
const [alsoClearCli, setAlsoClearCli] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiJson('/api/settings/hf-token/state');
|
||||
setState(data);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e?.message ||
|
||||
t('settings.hf_token_load_error', { defaultValue: 'Failed to load token state' }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
// `fresh` busts the backend's 300s whoami cache — used by "Test now" so it
|
||||
// really re-runs whoami instead of echoing a cached (possibly stale) verdict.
|
||||
// Plain mounts/refreshes keep the cache so Settings visits stay cheap.
|
||||
const refresh = useCallback(
|
||||
async ({ fresh = false } = {}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiJson(`/api/settings/hf-token/state${fresh ? '?fresh=1' : ''}`);
|
||||
setState(data);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e?.message ||
|
||||
t('settings.hf_token_load_error', { defaultValue: 'Failed to load token state' }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const onSave = async () => {
|
||||
// `saving` mirrors the button's disabled state for the input's Enter path,
|
||||
// closing the double-submit hole (Enter fired POSTs while one was in flight).
|
||||
if (saving) return;
|
||||
const token = tokenInput.trim();
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
@@ -131,7 +133,7 @@ export default function ApiKeysPanel() {
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-transparent px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-fg)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={refresh}
|
||||
onClick={() => refresh({ fresh: true })}
|
||||
disabled={loading}
|
||||
aria-label={testNowLabel}
|
||||
title={t('settings.hf_token_test_now_title', {
|
||||
@@ -151,106 +153,122 @@ export default function ApiKeysPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-[var(--space-3)]"
|
||||
role="table"
|
||||
aria-label={t('settings.hf_token_sources', { defaultValue: 'HF token sources' })}
|
||||
>
|
||||
{state.sources.map((row) => {
|
||||
const isActive = state.active === row.source;
|
||||
return (
|
||||
<div
|
||||
key={row.source}
|
||||
className={`apikeys-row ${isActive ? 'apikeys-row--active' : ''}`}
|
||||
role="row"
|
||||
data-source={row.source}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-[var(--space-3)]">
|
||||
<span className="inline-flex items-center gap-[var(--space-2)] text-[length:var(--text-md)] font-medium text-[var(--chrome-fg)]">
|
||||
{SOURCE_LABELS[row.source]}
|
||||
<InfoHint>{SOURCE_HELP[row.source]}</InfoHint>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="apikeys-badge apikeys-badge--active">
|
||||
{t('settings.hf_token_active', { defaultValue: 'Active' })}
|
||||
{!state ? (
|
||||
// First load still in flight (or failed — the banner above explains and
|
||||
// "Test now" doubles as retry). Never show a wrong "not set" verdict.
|
||||
<div
|
||||
className="py-[var(--space-4)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]"
|
||||
role="status"
|
||||
data-testid="hf-token-loading"
|
||||
>
|
||||
{loading && t('settings.hf_token_checking', { defaultValue: 'Checking token sources…' })}
|
||||
</div>
|
||||
) : (
|
||||
/* Visually a stack of cards, not a data grid — list semantics are the
|
||||
valid ARIA fit (the old role="table" had rows with no cells). */
|
||||
<div
|
||||
className="flex flex-col gap-[var(--space-3)]"
|
||||
role="list"
|
||||
aria-label={t('settings.hf_token_sources', { defaultValue: 'HF token sources' })}
|
||||
>
|
||||
{state.sources.map((row) => {
|
||||
const isActive = state.active === row.source;
|
||||
return (
|
||||
<div
|
||||
key={row.source}
|
||||
className={`apikeys-row ${isActive ? 'apikeys-row--active' : ''}`}
|
||||
role="listitem"
|
||||
data-source={row.source}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-[var(--space-3)]">
|
||||
<span className="inline-flex items-center gap-[var(--space-2)] text-[length:var(--text-md)] font-medium text-[var(--chrome-fg)]">
|
||||
{SOURCE_LABELS[row.source]}
|
||||
<InfoHint>{SOURCE_HELP[row.source]}</InfoHint>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-[var(--space-3)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
|
||||
{row.set ? (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-ok)]"
|
||||
aria-label={t('settings.hf_token_set', { defaultValue: 'set' })}
|
||||
>
|
||||
<CheckCircle2 size={12} />{' '}
|
||||
{t('settings.hf_token_set', { defaultValue: 'set' })}
|
||||
{isActive && (
|
||||
<span className="apikeys-badge apikeys-badge--active">
|
||||
{t('settings.hf_token_active', { defaultValue: 'Active' })}
|
||||
</span>
|
||||
{row.masked && (
|
||||
<code className="rounded-[4px] bg-[var(--chrome-hover-bg)] px-[6px] py-[1px] font-mono text-[length:var(--text-xs)]">
|
||||
{row.masked}
|
||||
</code>
|
||||
)}
|
||||
{row.whoami_ok ? (
|
||||
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-ok)]">
|
||||
<CheckCircle2 size={12} />{' '}
|
||||
{row.whoami_user ||
|
||||
t('settings.hf_token_verified', { defaultValue: 'verified' })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-err)]">
|
||||
<XCircle size={12} />{' '}
|
||||
{t('settings.hf_token_whoami_failed', { defaultValue: 'whoami failed' })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-warn)]">
|
||||
<XCircle size={12} />{' '}
|
||||
{t('settings.hf_token_not_set', { defaultValue: 'not set' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.source === 'app' && (
|
||||
<div className="mt-[var(--space-2)] flex flex-wrap items-center gap-[var(--space-3)]">
|
||||
<input
|
||||
type="password"
|
||||
className="box-border min-w-0 max-w-full flex-[1_1_220px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] font-mono text-[length:var(--text-sm)] text-[var(--chrome-fg)] focus:border-[var(--chrome-accent)] focus:outline-none"
|
||||
placeholder="hf_…"
|
||||
aria-label={t('settings.hf_token_input', { defaultValue: 'HuggingFace token' })}
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSave();
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-accent)] bg-[color-mix(in_srgb,var(--chrome-accent)_25%,var(--chrome-bg))] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-fg)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onSave}
|
||||
disabled={!tokenInput.trim() || saving}
|
||||
>
|
||||
<Save size={12} /> {t('common.save')}
|
||||
</button>
|
||||
{row.set && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_color-mix(in_srgb,var(--chrome-severity-err)_35%,var(--chrome-border))] bg-[var(--chrome-bg)] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-severity-err)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setClearOpen(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Trash2 size={12} />{' '}
|
||||
{t('settings.hf_token_clear_short', { defaultValue: 'Clear' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-[var(--space-3)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
|
||||
{row.set ? (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-ok)]"
|
||||
aria-label={t('settings.hf_token_set', { defaultValue: 'set' })}
|
||||
>
|
||||
<CheckCircle2 size={12} />{' '}
|
||||
{t('settings.hf_token_set', { defaultValue: 'set' })}
|
||||
</span>
|
||||
{row.masked && (
|
||||
<code className="rounded-[4px] bg-[var(--chrome-hover-bg)] px-[6px] py-[1px] font-mono text-[length:var(--text-xs)]">
|
||||
{row.masked}
|
||||
</code>
|
||||
)}
|
||||
{row.whoami_ok ? (
|
||||
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-ok)]">
|
||||
<CheckCircle2 size={12} />{' '}
|
||||
{row.whoami_user ||
|
||||
t('settings.hf_token_verified', { defaultValue: 'verified' })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-err)]">
|
||||
<XCircle size={12} />{' '}
|
||||
{t('settings.hf_token_whoami_failed', { defaultValue: 'whoami failed' })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-[4px] text-[var(--chrome-severity-warn)]">
|
||||
<XCircle size={12} />{' '}
|
||||
{t('settings.hf_token_not_set', { defaultValue: 'not set' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.source === 'app' && (
|
||||
<div className="mt-[var(--space-2)] flex flex-wrap items-center gap-[var(--space-3)]">
|
||||
<input
|
||||
type="password"
|
||||
className="box-border min-w-0 max-w-full flex-[1_1_220px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] font-mono text-[length:var(--text-sm)] text-[var(--chrome-fg)] focus:border-[var(--chrome-accent)] focus:outline-none"
|
||||
placeholder="hf_…"
|
||||
aria-label={t('settings.hf_token_input', {
|
||||
defaultValue: 'HuggingFace token',
|
||||
})}
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSave();
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-accent)] bg-[color-mix(in_srgb,var(--chrome-accent)_25%,var(--chrome-bg))] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-fg)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onSave}
|
||||
disabled={!tokenInput.trim() || saving}
|
||||
>
|
||||
<Save size={12} /> {t('common.save')}
|
||||
</button>
|
||||
{row.set && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-[5px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_color-mix(in_srgb,var(--chrome-severity-err)_35%,var(--chrome-border))] bg-[var(--chrome-bg)] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-sm)] font-medium text-[var(--chrome-severity-err)] hover:enabled:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setClearOpen(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Trash2 size={12} />{' '}
|
||||
{t('settings.hf_token_clear_short', { defaultValue: 'Clear' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clearOpen && (
|
||||
<div
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('ApiKeysPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('"Test now" button refetches state', async () => {
|
||||
it('"Test now" busts the whoami cache (?fresh=1); plain mounts stay cached', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ status: 200, body: STATE_THREE_UNSET },
|
||||
{ status: 200, body: STATE_THREE_UNSET },
|
||||
@@ -159,11 +159,88 @@ describe('ApiKeysPanel', () => {
|
||||
|
||||
render(<ApiKeysPanel />);
|
||||
await waitFor(() => screen.getByPlaceholderText(/hf_/));
|
||||
// Mount GET keeps the backend cache — no fresh param.
|
||||
expect(fetchMock.mock.calls[0][0]).not.toMatch(/fresh=1/);
|
||||
|
||||
const testBtn = screen.getByRole('button', { name: /test now/i });
|
||||
fireEvent.click(testBtn);
|
||||
|
||||
// The button claims to re-run whoami, so it must actually bypass the
|
||||
// backend's 300s validation cache.
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toMatch(/\/api\/settings\/hf-token\/state\?fresh=1$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('initial load shows a checking placeholder, never a false "not set" verdict', async () => {
|
||||
let resolveFetch;
|
||||
global.fetch = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
const { container } = render(<ApiKeysPanel />);
|
||||
|
||||
// While the GET is in flight: placeholder, no source rows, no verdicts.
|
||||
expect(screen.getByTestId('hf-token-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/not set/i)).toBeNull();
|
||||
expect(container.querySelectorAll('.apikeys-row').length).toBe(0);
|
||||
|
||||
resolveFetch({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => STATE_APP_ACTIVE,
|
||||
text: async () => JSON.stringify(STATE_APP_ACTIVE),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('.apikeys-row').length).toBe(3);
|
||||
expect(screen.queryByTestId('hf-token-loading')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the sources as a valid ARIA list (no cell-less table)', async () => {
|
||||
global.fetch = mockFetchOnce(STATE_THREE_UNSET);
|
||||
render(<ApiKeysPanel />);
|
||||
const list = await screen.findByRole('list', { name: /HF token sources/i });
|
||||
expect(list.querySelectorAll('[role="listitem"]').length).toBe(3);
|
||||
});
|
||||
|
||||
it('Enter while a save is in flight does not fire a duplicate POST', async () => {
|
||||
let resolvePost;
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => STATE_THREE_UNSET,
|
||||
text: async () => JSON.stringify(STATE_THREE_UNSET),
|
||||
})
|
||||
.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePost = resolve;
|
||||
}),
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
|
||||
render(<ApiKeysPanel />);
|
||||
const input = await screen.findByPlaceholderText(/hf_/);
|
||||
fireEvent.change(input, { target: { value: 'hf_newtoken123' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
fireEvent.keyDown(input, { key: 'Enter' }); // Save button is disabled; Enter must be too.
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
const posts = fetchMock.mock.calls.filter(([, opts]) => opts?.method === 'POST');
|
||||
expect(posts.length).toBe(1);
|
||||
});
|
||||
resolvePost({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => STATE_APP_ACTIVE,
|
||||
text: async () => JSON.stringify(STATE_APP_ACTIVE),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,39 @@ const THEMES = [
|
||||
{ id: 'catppuccin', label: 'Catppuccin', dot: '#cba6f7' },
|
||||
];
|
||||
|
||||
/**
|
||||
* WAI-ARIA radio-group keyboard support for the theme-dot / font-tile pickers:
|
||||
* arrow keys move selection (wrapping), Home/End jump to the ends, and focus
|
||||
* follows selection. Pair with `radioTabIndex` for the roving tabindex so the
|
||||
* group occupies a single tab stop, as the announced role promises.
|
||||
*/
|
||||
function radioGroupKeyDown(e, values, current, select) {
|
||||
const STEP = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||
let next;
|
||||
if (e.key in STEP) {
|
||||
const idx = Math.max(0, values.indexOf(current));
|
||||
next = values[(idx + STEP[e.key] + values.length) % values.length];
|
||||
} else if (e.key === 'Home') {
|
||||
next = values[0];
|
||||
} else if (e.key === 'End') {
|
||||
next = values[values.length - 1];
|
||||
}
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
select(next);
|
||||
const el = e.currentTarget
|
||||
.closest('[role="radiogroup"]')
|
||||
?.querySelector(`[data-radio-value="${next}"]`);
|
||||
el?.focus();
|
||||
}
|
||||
|
||||
/** Roving tabindex: only the checked radio (or the first, if none is checked
|
||||
* — e.g. a stale persisted value) is tabbable. */
|
||||
function radioTabIndex(values, current, value) {
|
||||
const focusable = values.includes(current) ? current : values[0];
|
||||
return value === focusable ? 0 : -1;
|
||||
}
|
||||
|
||||
export default function AppearancePanel() {
|
||||
const { t } = useTranslation();
|
||||
const uiScale = useAppStore((s) => s.uiScale);
|
||||
@@ -37,6 +70,8 @@ export default function AppearancePanel() {
|
||||
const scaleLabel = t('settings.ui_scale', { defaultValue: 'UI scale' });
|
||||
const themeLabel = t('settings.color_theme', { defaultValue: 'Color theme' });
|
||||
const fontLabel = t('settings.font', { defaultValue: 'Font' });
|
||||
const themeIds = THEMES.map((th) => th.id);
|
||||
const fontIds = FONT_OPTIONS.map((f) => f.id);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -89,10 +124,13 @@ export default function AppearancePanel() {
|
||||
className={`appearance-panel__theme-dot ${theme === th.id ? 'is-active' : ''}`}
|
||||
style={{ '--dot-color': th.dot }}
|
||||
onClick={() => setTheme(th.id)}
|
||||
onKeyDown={(e) => radioGroupKeyDown(e, themeIds, theme, setTheme)}
|
||||
title={th.label}
|
||||
aria-label={th.label}
|
||||
aria-checked={theme === th.id}
|
||||
role="radio"
|
||||
tabIndex={radioTabIndex(themeIds, theme, th.id)}
|
||||
data-radio-value={th.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -117,10 +155,13 @@ export default function AppearancePanel() {
|
||||
role="radio"
|
||||
aria-checked={font === f.id}
|
||||
aria-label={f.label}
|
||||
tabIndex={radioTabIndex(fontIds, font, f.id)}
|
||||
data-radio-value={f.id}
|
||||
data-testid={`appearance-font-${f.id}`}
|
||||
className={`appearance-panel__font-tile ${font === f.id ? 'is-active' : ''}`}
|
||||
style={{ fontFamily: FONT_STACKS[f.id] || 'var(--font-sans)' }}
|
||||
onClick={() => setFont(f.id)}
|
||||
onKeyDown={(e) => radioGroupKeyDown(e, fontIds, font, setFont)}
|
||||
>
|
||||
<span className="appearance-panel__font-sample">Ag</span>
|
||||
<span className="appearance-panel__font-name">{f.label}</span>
|
||||
|
||||
@@ -50,6 +50,56 @@ describe('AppearancePanel — global font selection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppearancePanel — WAI-ARIA radio-group keyboard pattern', () => {
|
||||
const fontIds = FONT_OPTIONS.map((f) => f.id);
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.getState().setFont(fontIds[0]);
|
||||
useAppStore.getState().setTheme('gruvbox');
|
||||
document.documentElement.style.removeProperty('--font-sans');
|
||||
});
|
||||
|
||||
it('roving tabindex: only the checked font tile is tabbable', () => {
|
||||
render(<AppearancePanel />);
|
||||
expect(screen.getByTestId(`appearance-font-${fontIds[0]}`)).toHaveAttribute('tabindex', '0');
|
||||
for (const id of fontIds.slice(1)) {
|
||||
expect(screen.getByTestId(`appearance-font-${id}`)).toHaveAttribute('tabindex', '-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('ArrowRight moves font selection and focus to the next tile', () => {
|
||||
render(<AppearancePanel />);
|
||||
const first = screen.getByTestId(`appearance-font-${fontIds[0]}`);
|
||||
first.focus();
|
||||
fireEvent.keyDown(first, { key: 'ArrowRight' });
|
||||
|
||||
expect(useAppStore.getState().font).toBe(fontIds[1]);
|
||||
const second = screen.getByTestId(`appearance-font-${fontIds[1]}`);
|
||||
expect(second).toHaveFocus();
|
||||
expect(second).toHaveAttribute('aria-checked', 'true');
|
||||
// Roving tabindex followed the selection.
|
||||
expect(second).toHaveAttribute('tabindex', '0');
|
||||
expect(first).toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
|
||||
it('ArrowLeft wraps from the first font to the last', () => {
|
||||
render(<AppearancePanel />);
|
||||
const first = screen.getByTestId(`appearance-font-${fontIds[0]}`);
|
||||
first.focus();
|
||||
fireEvent.keyDown(first, { key: 'ArrowLeft' });
|
||||
expect(useAppStore.getState().font).toBe(fontIds[fontIds.length - 1]);
|
||||
});
|
||||
|
||||
it('arrow keys move the theme-dot selection too', () => {
|
||||
render(<AppearancePanel />);
|
||||
const gruvbox = screen.getByRole('radio', { name: 'Gruvbox' });
|
||||
gruvbox.focus();
|
||||
fireEvent.keyDown(gruvbox, { key: 'ArrowDown' });
|
||||
expect(useAppStore.getState().theme).toBe('midnight');
|
||||
expect(screen.getByRole('radio', { name: 'Midnight' })).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppearancePanel — auto-play preview toggle (#666)', () => {
|
||||
it('defaults to ON (preserves existing auto-play behavior)', () => {
|
||||
expect(useAppStore.getState().autoPlayPreview).toBe(true);
|
||||
|
||||
@@ -27,7 +27,12 @@ export default function AsrOpenAICompatPanel() {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
// Last server-acknowledged values: the one Save button persists all three
|
||||
// fields, so it stays disabled until something actually differs (dirty) and
|
||||
// a successful save shows an explicit "Saved" confirmation.
|
||||
const [server, setServer] = useState({ base_url: '', model: '' });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
@@ -37,6 +42,7 @@ export default function AsrOpenAICompatPanel() {
|
||||
setModel(d?.model || '');
|
||||
setHasKey(Boolean(d?.has_key));
|
||||
setApiKey(''); // the key is never returned — the field always starts blank
|
||||
setServer({ base_url: d?.base_url || '', model: d?.model || '' });
|
||||
} catch (e) {
|
||||
setError(e?.message || t('models.asrOpenAICompatLoadError'));
|
||||
}
|
||||
@@ -68,6 +74,8 @@ export default function AsrOpenAICompatPanel() {
|
||||
setModel(d.model || '');
|
||||
setHasKey(Boolean(d.has_key));
|
||||
setApiKey('');
|
||||
setServer({ base_url: d.base_url || '', model: d.model || '' });
|
||||
setSaved(true);
|
||||
} catch (e) {
|
||||
setError(e?.message || t('models.asrOpenAICompatSaveError'));
|
||||
} finally {
|
||||
@@ -75,6 +83,8 @@ export default function AsrOpenAICompatPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = baseUrl !== server.base_url || model !== server.model || apiKey !== '';
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Mic}
|
||||
@@ -139,11 +149,20 @@ export default function AsrOpenAICompatPanel() {
|
||||
size="sm"
|
||||
onClick={save}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
disabled={saving || !dirty}
|
||||
data-testid="asr-openai-compat-save"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{saved && !dirty && !saving && (
|
||||
<span
|
||||
className="text-[length:var(--text-xs)] text-[color:var(--chrome-fg-dim)]"
|
||||
role="status"
|
||||
data-testid="asr-openai-compat-saved"
|
||||
>
|
||||
{t('models.asrOpenAICompatSaved')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Settings → Audio tools — the power-user surface for the media tools most
|
||||
* users never see (the wizard + backend provision them invisibly).
|
||||
*
|
||||
* One row per tool:
|
||||
* • FFmpeg / FFprobe — version + origin badge (Bundled / System / Custom /
|
||||
* App package) + path; actions: Use system copy (auto-detect),
|
||||
* Choose file… (picker in Tauri, inline path input everywhere),
|
||||
* Restore bundled (always-safe revert). The section header carries
|
||||
* "Update bundled build" (one download covers both binaries).
|
||||
* • yt-dlp — module version + Update (fetches the newest wheel into an
|
||||
* update-surviving overlay; applies on restart) + Restore tested version.
|
||||
*
|
||||
* Absorbs the FFmpeg-path override that used to live in Settings → Network —
|
||||
* same backend store (prefs `env.FFMPEG_PATH`), one control surface.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, Film, ScanSearch, DownloadCloud } from 'lucide-react';
|
||||
import { Button, Badge } from '../../ui';
|
||||
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
|
||||
import RestartBadge from './RestartBadge';
|
||||
import { isTauri } from './native';
|
||||
|
||||
const ORIGIN_TONE = {
|
||||
bundled: 'success',
|
||||
sidecar: 'success',
|
||||
system: 'info',
|
||||
custom: 'warn',
|
||||
};
|
||||
|
||||
function OriginBadge({ origin }) {
|
||||
const { t } = useTranslation();
|
||||
if (!origin) return null;
|
||||
const labels = {
|
||||
bundled: t('settings.audio_tools_origin_bundled', { defaultValue: 'Bundled' }),
|
||||
system: t('settings.audio_tools_origin_system', { defaultValue: 'System' }),
|
||||
custom: t('settings.audio_tools_origin_custom', { defaultValue: 'Custom' }),
|
||||
sidecar: t('settings.audio_tools_origin_sidecar', { defaultValue: 'App package' }),
|
||||
};
|
||||
return (
|
||||
<Badge tone={ORIGIN_TONE[origin] || 'neutral'} size="xs" data-testid={`origin-${origin}`}>
|
||||
{labels[origin] || origin}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the OS file picker in Tauri; return the chosen path or null. */
|
||||
async function pickBinary(title) {
|
||||
if (!isTauri()) return null;
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const picked = await open({ multiple: false, directory: false, title });
|
||||
return typeof picked === 'string' ? picked : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function BinaryRow({ tool, info, onAction, busy }) {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState('');
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const label = tool === 'ffmpeg' ? 'FFmpeg' : 'FFprobe';
|
||||
|
||||
const chooseFile = async () => {
|
||||
const picked = await pickBinary(label);
|
||||
if (picked) {
|
||||
onAction(`/media-tools/${tool}/custom-path`, { path: picked });
|
||||
} else {
|
||||
// Web preview / picker unavailable — fall back to the inline input.
|
||||
setShowInput(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
align="start"
|
||||
stack
|
||||
icon={tool === 'ffmpeg' ? Film : ScanSearch}
|
||||
title={
|
||||
<>
|
||||
{label}
|
||||
<OriginBadge origin={info?.origin} />
|
||||
{!info?.ok && (
|
||||
<Badge tone="warn" size="xs">
|
||||
{t('settings.audio_tools_not_found', { defaultValue: 'Not available' })}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
info?.ok ? (
|
||||
<>
|
||||
{info.version ||
|
||||
t('settings.audio_tools_version_unknown', { defaultValue: 'version unknown' })}
|
||||
{' — '}
|
||||
<code className="font-mono">{info.path}</code>
|
||||
</>
|
||||
) : (
|
||||
t(`settings.audio_tools_${tool}_desc`)
|
||||
)
|
||||
}
|
||||
control={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => onAction(`/media-tools/${tool}/use-system`)}
|
||||
aria-label={`${label}: ${t('settings.audio_tools_use_system')}`}
|
||||
>
|
||||
{t('settings.audio_tools_use_system', { defaultValue: 'Use system copy' })}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={chooseFile}
|
||||
aria-label={`${label}: ${t('settings.audio_tools_choose_file')}`}
|
||||
>
|
||||
{t('settings.audio_tools_choose_file', { defaultValue: 'Choose file…' })}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => onAction(`/media-tools/${tool}/restore`)}
|
||||
aria-label={`${label}: ${t('settings.audio_tools_restore')}`}
|
||||
>
|
||||
{t('settings.audio_tools_restore', { defaultValue: 'Restore bundled' })}
|
||||
</Button>
|
||||
{showInput && (
|
||||
<>
|
||||
<SettingsInput
|
||||
placeholder={tool === 'ffmpeg' ? '/usr/bin/ffmpeg' : '/usr/bin/ffprobe'}
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' &&
|
||||
path.trim() &&
|
||||
onAction(`/media-tools/${tool}/custom-path`, { path: path.trim() })
|
||||
}
|
||||
aria-label={t('settings.audio_tools_path_input_aria', {
|
||||
tool: label,
|
||||
defaultValue: '{{tool}} binary path',
|
||||
})}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={busy || !path.trim()}
|
||||
onClick={() => onAction(`/media-tools/${tool}/custom-path`, { path: path.trim() })}
|
||||
>
|
||||
{t('credentials.save', { defaultValue: 'Save' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AudioToolsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const acquireWasRunning = useRef(false);
|
||||
const ytdlpWasRunning = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { apiJson } = await import('../../api/client');
|
||||
const st = await apiJson('/media-tools/status');
|
||||
setStatus(st);
|
||||
return st;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Poll while a background op runs; toast exactly once on the edge.
|
||||
const acquire = status?.ops?.acquire;
|
||||
const ytdlpOp = status?.ops?.ytdlp_update;
|
||||
useEffect(() => {
|
||||
if (acquire?.state === 'running') acquireWasRunning.current = true;
|
||||
else if (acquireWasRunning.current) {
|
||||
acquireWasRunning.current = false;
|
||||
if (acquire?.state === 'done') {
|
||||
toast.success(
|
||||
t('settings.audio_tools_bundle_done', { defaultValue: 'Bundled media engine ready.' }),
|
||||
);
|
||||
} else if (acquire?.state === 'error') {
|
||||
toast.error(
|
||||
t('settings.audio_tools_bundle_failed', {
|
||||
message: acquire.error,
|
||||
defaultValue: 'Bundled download failed: {{message}}',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ytdlpOp?.state === 'running') ytdlpWasRunning.current = true;
|
||||
else if (ytdlpWasRunning.current) {
|
||||
ytdlpWasRunning.current = false;
|
||||
if (ytdlpOp?.state === 'done') {
|
||||
toast.success(
|
||||
t('settings.audio_tools_ytdlp_updated', {
|
||||
version: ytdlpOp.version,
|
||||
defaultValue: 'yt-dlp {{version}} installed — restart the backend to apply.',
|
||||
}),
|
||||
);
|
||||
} else if (ytdlpOp?.state === 'error') {
|
||||
toast.error(
|
||||
t('settings.audio_tools_ytdlp_update_failed', {
|
||||
message: ytdlpOp.error,
|
||||
defaultValue: 'yt-dlp update failed: {{message}}',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (acquire?.state !== 'running' && ytdlpOp?.state !== 'running') return undefined;
|
||||
const iv = setInterval(load, 1500);
|
||||
return () => clearInterval(iv);
|
||||
}, [acquire?.state, ytdlpOp?.state, load, t, acquire?.error, ytdlpOp?.error, ytdlpOp?.version]);
|
||||
|
||||
const post = useCallback(
|
||||
async (path, body) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { apiFetch } = await import('../../api/client');
|
||||
const res = await apiFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
detail = (await res.json())?.detail || detail;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t('settings.audio_tools_path_failed', {
|
||||
message: e.message,
|
||||
defaultValue: "Couldn't set path: {{message}}",
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
load();
|
||||
}
|
||||
},
|
||||
[load, t],
|
||||
);
|
||||
|
||||
const onToolAction = useCallback(
|
||||
async (path, body) => {
|
||||
const ok = await post(path, body);
|
||||
if (ok && (path.endsWith('/custom-path') || path.endsWith('/use-system'))) {
|
||||
toast.success(
|
||||
t('settings.audio_tools_path_set', {
|
||||
tool: path.includes('ffprobe') ? 'FFprobe' : 'FFmpeg',
|
||||
path: body?.path || t('settings.audio_tools_origin_system', { defaultValue: 'System' }),
|
||||
defaultValue: '{{tool}} now uses {{path}}',
|
||||
}),
|
||||
);
|
||||
} else if (ok && path.endsWith('/restore')) {
|
||||
toast.success(
|
||||
t('settings.audio_tools_restored', {
|
||||
tool: path.includes('ffprobe') ? 'FFprobe' : 'FFmpeg',
|
||||
defaultValue: '{{tool}} restored to the app-managed build.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[post, t],
|
||||
);
|
||||
|
||||
const ytdlp = status?.tools?.ytdlp;
|
||||
const ytdlpNeedsRestart =
|
||||
ytdlpOp?.state === 'done' ||
|
||||
(ytdlp?.overlay_version && ytdlp.overlay_version !== ytdlp.version);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={AudioLines}
|
||||
title={t('settings.audio_tools', { defaultValue: 'Audio tools' })}
|
||||
description={t('settings.audio_tools_desc', {
|
||||
defaultValue:
|
||||
'The media engine (FFmpeg, FFprobe) and video downloader (yt-dlp) the app manages for you.',
|
||||
})}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leading={<DownloadCloud size={12} />}
|
||||
loading={acquire?.state === 'running'}
|
||||
disabled={busy || acquire?.state === 'running'}
|
||||
onClick={() => post('/media-tools/acquire')}
|
||||
aria-label={t('settings.audio_tools_update_bundle', {
|
||||
defaultValue: 'Update bundled build',
|
||||
})}
|
||||
>
|
||||
{acquire?.state === 'running'
|
||||
? t('settings.audio_tools_bundle_updating', {
|
||||
percent: Math.round((acquire.progress || 0) * 100),
|
||||
defaultValue: 'Downloading bundled build… {{percent}}%',
|
||||
})
|
||||
: t('settings.audio_tools_update_bundle', { defaultValue: 'Update bundled build' })}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<BinaryRow tool="ffmpeg" info={status?.tools?.ffmpeg} onAction={onToolAction} busy={busy} />
|
||||
<BinaryRow tool="ffprobe" info={status?.tools?.ffprobe} onAction={onToolAction} busy={busy} />
|
||||
|
||||
<SettingRow
|
||||
align="start"
|
||||
stack
|
||||
icon={DownloadCloud}
|
||||
title={
|
||||
<>
|
||||
{t('settings.audio_tools_ytdlp', { defaultValue: 'yt-dlp (video downloader)' })}
|
||||
{ytdlp?.origin && (
|
||||
<OriginBadge origin={ytdlp.origin === 'custom' ? 'custom' : 'bundled'} />
|
||||
)}
|
||||
{ytdlpNeedsRestart && <RestartBadge />}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
<>
|
||||
{ytdlp?.version ||
|
||||
t('settings.audio_tools_version_unknown', { defaultValue: 'version unknown' })}
|
||||
{' — '}
|
||||
{t('settings.audio_tools_ytdlp_desc', {
|
||||
defaultValue:
|
||||
'Powers video/clip imports. Site support changes faster than app releases — update it here when imports start failing.',
|
||||
})}
|
||||
</>
|
||||
}
|
||||
hint={t('settings.audio_tools_manual_hint', {
|
||||
defaultValue:
|
||||
'Prefer your package manager? Install FFmpeg yourself (macOS: brew install ffmpeg · Debian/Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg) and press Use system copy. Nothing is ever installed system-wide by the app.',
|
||||
})}
|
||||
control={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
loading={ytdlpOp?.state === 'running'}
|
||||
disabled={busy || ytdlpOp?.state === 'running'}
|
||||
onClick={() => post('/media-tools/ytdlp/update')}
|
||||
aria-label={`yt-dlp: ${t('settings.audio_tools_ytdlp_update', { defaultValue: 'Update' })}`}
|
||||
>
|
||||
{t('settings.audio_tools_ytdlp_update', { defaultValue: 'Update' })}
|
||||
</Button>
|
||||
{(ytdlp?.origin === 'custom' || ytdlp?.overlay_version) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy || ytdlpOp?.state === 'running'}
|
||||
onClick={async () => {
|
||||
const ok = await post('/media-tools/ytdlp/restore');
|
||||
if (ok) {
|
||||
toast.success(
|
||||
t('settings.audio_tools_ytdlp_restored', {
|
||||
defaultValue: 'Tested yt-dlp restored — restart the backend to apply.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
aria-label={`yt-dlp: ${t('settings.audio_tools_ytdlp_restore', { defaultValue: 'Restore tested version' })}`}
|
||||
data-testid="ytdlp-restore"
|
||||
>
|
||||
{t('settings.audio_tools_ytdlp_restore', {
|
||||
defaultValue: 'Restore tested version',
|
||||
})}
|
||||
{ytdlp?.baseline_version ? ` (${ytdlp.baseline_version})` : ''}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
apiJson: vi.fn(),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import AudioToolsPanel from './AudioToolsPanel';
|
||||
|
||||
const STATUS = {
|
||||
ready: true,
|
||||
platform_key: 'darwin_arm64',
|
||||
tools: {
|
||||
ffmpeg: {
|
||||
tool: 'ffmpeg',
|
||||
ok: true,
|
||||
path: '/data/media_tools/ffbin-abc/darwin_arm64/ffmpeg',
|
||||
version: '7.0',
|
||||
origin: 'bundled',
|
||||
},
|
||||
ffprobe: {
|
||||
tool: 'ffprobe',
|
||||
ok: true,
|
||||
path: '/opt/homebrew/bin/ffprobe',
|
||||
version: '8.1.1',
|
||||
origin: 'system',
|
||||
},
|
||||
ytdlp: {
|
||||
tool: 'yt-dlp',
|
||||
ok: true,
|
||||
path: '/venv/site-packages/yt_dlp',
|
||||
version: '2026.06.09',
|
||||
origin: 'bundled',
|
||||
overlay_version: null,
|
||||
baseline_version: null,
|
||||
},
|
||||
},
|
||||
ops: {
|
||||
acquire: { state: 'idle', progress: 0, error: null },
|
||||
ytdlp_update: { state: 'idle', progress: 0, error: null, version: null },
|
||||
},
|
||||
};
|
||||
|
||||
const okResponse = { ok: true, json: async () => ({}) };
|
||||
|
||||
describe('AudioToolsPanel — power-user surface for the media tools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiJson.mockResolvedValue(JSON.parse(JSON.stringify(STATUS)));
|
||||
apiFetch.mockResolvedValue(okResponse);
|
||||
});
|
||||
|
||||
it('renders one row per tool with version, path, and origin badge', async () => {
|
||||
render(<AudioToolsPanel />);
|
||||
await waitFor(() => expect(apiJson).toHaveBeenCalledWith('/media-tools/status'));
|
||||
|
||||
expect(await screen.findByText('FFmpeg')).toBeInTheDocument();
|
||||
expect(screen.getByText('FFprobe')).toBeInTheDocument();
|
||||
expect(screen.getByText('yt-dlp (video downloader)')).toBeInTheDocument();
|
||||
|
||||
// ffmpeg + yt-dlp are both app-managed here; ffprobe is a system copy.
|
||||
const bundled = screen.getAllByTestId('origin-bundled');
|
||||
expect(bundled).toHaveLength(2);
|
||||
expect(bundled[0]).toHaveTextContent('Bundled');
|
||||
expect(screen.getByTestId('origin-system')).toHaveTextContent('System');
|
||||
expect(screen.getByText('/opt/homebrew/bin/ffprobe')).toBeInTheDocument();
|
||||
expect(screen.getByText(/2026\.06\.09/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Use system copy posts the endpoint and toasts success', async () => {
|
||||
render(<AudioToolsPanel />);
|
||||
fireEvent.click(await screen.findByLabelText('FFmpeg: Use system copy'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffmpeg/use-system', expect.anything()),
|
||||
);
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('Restore bundled is per-tool and always available (safe revert)', async () => {
|
||||
render(<AudioToolsPanel />);
|
||||
fireEvent.click(await screen.findByLabelText('FFprobe: Restore bundled'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffprobe/restore', expect.anything()),
|
||||
);
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('surfaces the backend error detail on a failed action', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ detail: 'That file exists but does not run as a media tool' }),
|
||||
});
|
||||
render(<AudioToolsPanel />);
|
||||
fireEvent.click(await screen.findByLabelText('FFmpeg: Use system copy'));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(String(toast.error.mock.calls[0][0])).toContain('does not run as a media tool');
|
||||
});
|
||||
|
||||
it('yt-dlp row: Update posts the update endpoint', async () => {
|
||||
render(<AudioToolsPanel />);
|
||||
fireEvent.click(await screen.findByLabelText('yt-dlp: Update'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ytdlp/update', expect.anything()),
|
||||
);
|
||||
});
|
||||
|
||||
it('yt-dlp row: Restore tested version appears only when an overlay is active', async () => {
|
||||
const { unmount } = render(<AudioToolsPanel />);
|
||||
await screen.findByText('yt-dlp (video downloader)');
|
||||
expect(screen.queryByTestId('ytdlp-restore')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
const overlaid = JSON.parse(JSON.stringify(STATUS));
|
||||
overlaid.tools.ytdlp.origin = 'custom';
|
||||
overlaid.tools.ytdlp.overlay_version = '2026.07.01';
|
||||
overlaid.tools.ytdlp.version = '2026.07.01';
|
||||
overlaid.tools.ytdlp.baseline_version = '2026.06.09';
|
||||
apiJson.mockResolvedValue(overlaid);
|
||||
|
||||
render(<AudioToolsPanel />);
|
||||
const restore = await screen.findByTestId('ytdlp-restore');
|
||||
expect(restore).toHaveTextContent('Restore tested version (2026.06.09)');
|
||||
fireEvent.click(restore);
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ytdlp/restore', expect.anything()),
|
||||
);
|
||||
});
|
||||
|
||||
it('section header offers Update bundled build (one download covers both binaries)', async () => {
|
||||
render(<AudioToolsPanel />);
|
||||
fireEvent.click(await screen.findByLabelText('Update bundled build'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith('/media-tools/acquire', expect.anything()),
|
||||
);
|
||||
});
|
||||
|
||||
it('package-manager commands are copy-only prose, never buttons', async () => {
|
||||
render(<AudioToolsPanel />);
|
||||
await screen.findByText('FFmpeg');
|
||||
// The InfoHint copy mentions brew/apt as a secondary affordance, but no
|
||||
// button/control runs a package manager.
|
||||
const buttons = screen.getAllByRole('button').map((b) => b.textContent || '');
|
||||
expect(buttons.join(' ')).not.toMatch(/brew|apt|winget|choco/i);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,26 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addBreadcrumb } from '../../utils/breadcrumbs';
|
||||
import { listEngines, selectEngine } from '../../api/engines';
|
||||
import { selectEngine } from '../../api/engines';
|
||||
import { notifyEngineSelected } from '../../utils/engineSelectToast';
|
||||
import EngineCompatibilityMatrix from '../EngineCompatibilityMatrix';
|
||||
import { SETTINGS_SECTION_SURFACE } from './primitives';
|
||||
|
||||
/** One pinned matrix per family, stacked in this order. ASR used to be
|
||||
* reachable only through the matrix's family tabs, which read as a
|
||||
* TTS-only table — README even promised a Settings ASR picker that
|
||||
* didn't exist (UX gap found during #877). Every family now gets a
|
||||
* visible picker; `OMNIVOICE_*_BACKEND` env vars still win over any pick. */
|
||||
const FAMILIES = ['tts', 'asr', 'llm'];
|
||||
|
||||
/** Settings → Engines: ONE section, one matrix, a TTS / ASR / LLM tab strip.
|
||||
*
|
||||
* The page used to stack three pinned per-family matrices; with every row
|
||||
* free to grow (wrapping names, stacked badges, inline failure prose) a
|
||||
* single engine could fill a viewport and the ASR/LLM pickers lived below
|
||||
* the fold. The matrix's family tab strip (Radix Segmented — roving
|
||||
* tabindex + arrow keys, active engine named in each tab caption) now
|
||||
* presents one family at a time instead, over compact fixed-height rows.
|
||||
*
|
||||
* Data contract is unchanged: the single mounted matrix issues exactly one
|
||||
* GET /engines + one GET /model/loaded per Settings open (switching tabs
|
||||
* re-slices the same payload — no refetch), `openSettingsTab('engines')`
|
||||
* still lands here, and `OMNIVOICE_*_BACKEND` env vars still win over any
|
||||
* pick made in the UI. */
|
||||
export default function EnginesTab() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -40,32 +47,9 @@ export default function EnginesTab() {
|
||||
[t],
|
||||
);
|
||||
|
||||
// The stacked matrices all consume the same GET /engines payload — share
|
||||
// one in-flight request so opening the tab probes every engine once, not
|
||||
// once per family. A per-matrix Refresh after the shared promise settles
|
||||
// still triggers a fresh fetch.
|
||||
const inflightList = useRef(null);
|
||||
const listEnginesShared = useCallback(() => {
|
||||
if (!inflightList.current) {
|
||||
inflightList.current = listEngines().finally(() => {
|
||||
inflightList.current = null;
|
||||
});
|
||||
}
|
||||
return inflightList.current;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{FAMILIES.map((family) => (
|
||||
<section key={family} className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
|
||||
<EngineCompatibilityMatrix
|
||||
family={family}
|
||||
showFamilyTabs={false}
|
||||
onSelect={onSelect}
|
||||
apiListEngines={listEnginesShared}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
<section className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
|
||||
<EngineCompatibilityMatrix family="tts" onSelect={onSelect} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,19 @@ vi.mock('../../api/engines', () => ({
|
||||
selectEngine: vi.fn(),
|
||||
getEngineHealth: vi.fn(),
|
||||
selfTestEngine: vi.fn(),
|
||||
installSidecarEngine: vi.fn(),
|
||||
getSidecarInstallStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
// Residency layer (/model/loaded) — mocked so the matrix never hits the
|
||||
// network in tests; the single-probe behavior is asserted below.
|
||||
vi.mock('../../api/system', () => ({
|
||||
listLoadedModels: vi.fn(),
|
||||
unloadLoadedModel: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listEngines, selectEngine } from '../../api/engines';
|
||||
import { listLoadedModels } from '../../api/system';
|
||||
import EnginesTab from './EnginesTab';
|
||||
|
||||
function entry(id, name) {
|
||||
@@ -43,31 +53,60 @@ const ENGINES = {
|
||||
llm: { active: 'off', backends: [entry('off', 'Off (test)')] },
|
||||
};
|
||||
|
||||
/** Click the family tab whose label text is `label` (TTS / ASR / LLM). */
|
||||
function clickFamilyTab(label) {
|
||||
const tab = Array.from(document.querySelectorAll('.engine-matrix__tab-family')).find(
|
||||
(el) => el.textContent === label,
|
||||
);
|
||||
expect(tab).toBeTruthy();
|
||||
fireEvent.click(tab.closest('button'));
|
||||
}
|
||||
|
||||
describe('EnginesTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listEngines.mockResolvedValue(ENGINES);
|
||||
listLoadedModels.mockResolvedValue({ models: [], count: 0 });
|
||||
});
|
||||
|
||||
it('renders a pinned picker per family — TTS, ASR and LLM all visible at once', async () => {
|
||||
it('renders ONE tabbed section — TTS/ASR/LLM tab strip, one family at a time', async () => {
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('WhisperX (test)'));
|
||||
await waitFor(() => screen.getByText('OmniVoice (test)'));
|
||||
|
||||
// One named section per family (the ASR picker used to be tucked behind
|
||||
// a family tab inside a single TTS-titled matrix — no picker to find).
|
||||
expect(screen.getByText('TTS Engines')).toBeInTheDocument();
|
||||
expect(screen.getByText('ASR Engines')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM Engines')).toBeInTheDocument();
|
||||
// Pinned matrices render no family switcher.
|
||||
expect(document.querySelector('.engine-matrix__tab-family')).toBeNull();
|
||||
// One settings card, not three stacked per-family matrices.
|
||||
expect(document.querySelectorAll('[data-slot="settings-section"]').length).toBe(1);
|
||||
// The tab strip offers all three families (with the active engine caption).
|
||||
expect(document.querySelectorAll('.engine-matrix__tab-family').length).toBe(3);
|
||||
// Only the selected family's engines are on screen.
|
||||
expect(screen.queryByText('WhisperX (test)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Off (test)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the stacked matrices share one GET /engines on mount', async () => {
|
||||
it('switching to the ASR tab shows ASR engines without refetching /engines', async () => {
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('OmniVoice (test)'));
|
||||
|
||||
clickFamilyTab('ASR');
|
||||
await waitFor(() => screen.getByText('WhisperX (test)'));
|
||||
expect(screen.getByText('OpenAI-compatible ASR (test)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('OmniVoice (test)')).not.toBeInTheDocument();
|
||||
// Tab switches re-slice the already-fetched payload — no second request.
|
||||
expect(listEngines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches GET /engines exactly once on mount', async () => {
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('OmniVoice (test)'));
|
||||
expect(listEngines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('probes GET /model/loaded exactly once on mount', async () => {
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('OmniVoice (test)'));
|
||||
await waitFor(() => expect(listLoadedModels).toHaveBeenCalled());
|
||||
expect(listLoadedModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Use on an ASR engine selects it with family="asr"', async () => {
|
||||
selectEngine.mockResolvedValue({
|
||||
family: 'asr',
|
||||
@@ -78,6 +117,9 @@ describe('EnginesTab', () => {
|
||||
routing_reason: null,
|
||||
});
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('OmniVoice (test)'));
|
||||
|
||||
clickFamilyTab('ASR');
|
||||
await waitFor(() => screen.getByText('OpenAI-compatible ASR (test)'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /use openai-compatible asr \(test\)/i }));
|
||||
|
||||
@@ -56,8 +56,14 @@ export default function GeneralTab() {
|
||||
value={reviewMode}
|
||||
onChange={setReviewMode}
|
||||
items={[
|
||||
{ value: 'on', label: t('engines.review_on') },
|
||||
{ value: 'off', label: t('engines.review_off') },
|
||||
{
|
||||
value: 'on',
|
||||
label: t('settings.review_mode_on', { defaultValue: 'Pause for review' }),
|
||||
},
|
||||
{
|
||||
value: 'off',
|
||||
label: t('settings.review_mode_off', { defaultValue: 'Run straight through' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
/**
|
||||
* Settings → Models tab → Hugging Face mirror panel (Wave 4.3).
|
||||
*
|
||||
* Restricted-network users (e.g. behind the Great Firewall) point
|
||||
* huggingface_hub at a mirror via HF_ENDPOINT. HF reads it at import time, so
|
||||
* the change applies after a restart. Persisted to the durable per-user env.
|
||||
* Default mode is **Auto (recommended)**: the backend probes the official
|
||||
* endpoint and the community mirror, picks whichever actually works
|
||||
* (preferring huggingface.co unless the mirror is decisively faster), and
|
||||
* remembers the pick — restricted-network users (e.g. behind the Great
|
||||
* Firewall) get working downloads without hunting for this panel. Downloads
|
||||
* are checksum-verified by Hugging Face regardless of endpoint.
|
||||
*
|
||||
* Explicit choices stay explicit: picking a preset or saving a custom URL
|
||||
* pins that endpoint (HF_ENDPOINT, persisted to the durable per-user env; HF
|
||||
* reads it at import time, so loads apply after a restart) and auto never
|
||||
* switches it. Existing configured endpoints load as the matching manual
|
||||
* mode — never migrated to Auto.
|
||||
*
|
||||
* Endpoints (loopback-only):
|
||||
* GET /api/settings/hf-mirror → {configured, effective, presets}
|
||||
* PUT /api/settings/hf-mirror body {url} (empty url clears → official)
|
||||
* GET /api/settings/hf-mirror → {configured, effective, presets, mode, auto}
|
||||
* PUT /api/settings/hf-mirror body {url, mode} (mode 'auto' clears the url)
|
||||
* POST /api/settings/hf-mirror/test → re-run the probe race ("Test again")
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { Globe, RefreshCw, Zap } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
|
||||
import { Button } from '../../ui';
|
||||
import RestartBadge from './RestartBadge';
|
||||
|
||||
/** Normalize a mirror URL for equality checks (trailing slashes, whitespace). */
|
||||
const normalizeMirror = (u) => (u || '').trim().replace(/\/+$/, '');
|
||||
|
||||
/** Host of the auto pick ("hf-mirror.com"), for compact display. */
|
||||
const hostOf = (url) => {
|
||||
try {
|
||||
return new URL(url).hostname || url;
|
||||
} catch {
|
||||
return url || '';
|
||||
}
|
||||
};
|
||||
|
||||
export default function HFMirrorPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState(null);
|
||||
const [url, setUrl] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [restart, setRestart] = useState(false);
|
||||
|
||||
@@ -40,19 +64,20 @@ export default function HFMirrorPanel() {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const save = async (value) => {
|
||||
const save = async (value, mode = 'manual') => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/hf-mirror', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: value }),
|
||||
body: JSON.stringify({ url: value, mode }),
|
||||
});
|
||||
const d = await res.json();
|
||||
setState(d);
|
||||
setUrl(d.configured || '');
|
||||
setRestart(Boolean(d.restart_required));
|
||||
refresh();
|
||||
toast.success(t('models.mirror_saved', { defaultValue: 'Mirror setting saved' }));
|
||||
} catch (e) {
|
||||
setError(e?.message || t('models.mirror_save_error'));
|
||||
} finally {
|
||||
@@ -60,8 +85,63 @@ export default function HFMirrorPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!state) return null;
|
||||
const testAgain = async () => {
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/hf-mirror/test', { method: 'POST' });
|
||||
const d = await res.json();
|
||||
setState(d);
|
||||
setUrl(d.configured || '');
|
||||
} catch (e) {
|
||||
setError(
|
||||
e?.message || t('models.mirror_auto_test_error', { defaultValue: 'Endpoint test failed' }),
|
||||
);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const configured = normalizeMirror(state?.configured);
|
||||
const isAuto = state?.mode === 'auto';
|
||||
const auto = state?.auto || null;
|
||||
|
||||
// Auto status line: pick + latency + last checked, or the untested/offline states.
|
||||
let autoStatus = null;
|
||||
if (isAuto) {
|
||||
if (!auto) {
|
||||
autoStatus = t('models.mirror_auto_untested', {
|
||||
defaultValue: 'Not tested yet — the next download picks the best endpoint automatically.',
|
||||
});
|
||||
} else if (!auto.reachable) {
|
||||
autoStatus = t('models.mirror_auto_offline', {
|
||||
defaultValue:
|
||||
'No Hugging Face endpoint reachable right now — cached models keep working; auto retries on the next download.',
|
||||
});
|
||||
} else {
|
||||
const parts = [hostOf(auto.endpoint)];
|
||||
if (typeof auto.latency_ms === 'number') {
|
||||
parts.push(
|
||||
t('models.mirror_auto_latency', {
|
||||
ms: Math.round(auto.latency_ms),
|
||||
defaultValue: '{{ms}} ms',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (auto.checked_at) {
|
||||
parts.push(
|
||||
t('models.mirror_auto_checked', {
|
||||
when: new Date(auto.checked_at * 1000).toLocaleString(),
|
||||
defaultValue: 'checked {{when}}',
|
||||
}),
|
||||
);
|
||||
}
|
||||
autoStatus = parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
// Always render the section shell: a restricted-network user whose backend
|
||||
// GET failed is exactly the user who needs this panel — never let it vanish.
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Globe}
|
||||
@@ -75,54 +155,128 @@ export default function HFMirrorPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
stack
|
||||
title={t('models.mirror_preset_title')}
|
||||
hint={t('models.mirror_preset_hint')}
|
||||
control={
|
||||
<div className="flex flex-wrap items-center gap-[6px] min-w-0 max-w-full">
|
||||
{state.presets.map((p) => (
|
||||
<Button
|
||||
variant="preset"
|
||||
key={p.label}
|
||||
onClick={() => save(p.url)}
|
||||
disabled={saving}
|
||||
data-testid={`hf-preset-${p.url || 'official'}`}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{!state && !error && (
|
||||
<div
|
||||
data-testid="hf-mirror-loading"
|
||||
className="py-[var(--space-4)] text-[color:var(--chrome-fg-muted)] text-[length:var(--text-sm)]"
|
||||
>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
stack
|
||||
title="HF_ENDPOINT"
|
||||
subtitle={restart ? t('models.mirror_restart_note') : undefined}
|
||||
control={
|
||||
<>
|
||||
<SettingsInput
|
||||
mono
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://hf-mirror.com"
|
||||
data-testid="hf-mirror-url"
|
||||
{!state && error && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leading={<RefreshCw size={13} aria-hidden="true" />}
|
||||
onClick={refresh}
|
||||
data-testid="hf-mirror-retry"
|
||||
>
|
||||
{t('models.mirror_retry', { defaultValue: 'Retry' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{state && (
|
||||
<>
|
||||
<SettingRow
|
||||
stack
|
||||
title={t('models.mirror_preset_title')}
|
||||
hint={t('models.mirror_preset_hint')}
|
||||
control={
|
||||
<div className="flex flex-wrap items-center gap-[6px] min-w-0 max-w-full">
|
||||
<Button
|
||||
variant="preset"
|
||||
active={isAuto}
|
||||
onClick={() => save('', 'auto')}
|
||||
disabled={saving}
|
||||
leading={<Zap size={12} aria-hidden="true" />}
|
||||
data-testid="hf-preset-auto"
|
||||
>
|
||||
{t('models.mirror_mode_auto', { defaultValue: 'Auto (recommended)' })}
|
||||
</Button>
|
||||
{state.presets.map((p) => (
|
||||
<Button
|
||||
variant="preset"
|
||||
key={p.label}
|
||||
active={!isAuto && normalizeMirror(p.url) === configured}
|
||||
onClick={() => save(p.url, 'manual')}
|
||||
disabled={saving}
|
||||
data-testid={`hf-preset-${p.url || 'official'}`}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{isAuto && (
|
||||
<SettingRow
|
||||
stack
|
||||
title={t('models.mirror_auto_title', { defaultValue: 'Automatic selection' })}
|
||||
note={t('models.mirror_auto_hint', {
|
||||
defaultValue:
|
||||
'OmniVoice probes huggingface.co and the community mirror, then uses whichever actually works — preferring the official endpoint unless the mirror is decisively faster. Downloads are checksum-verified by Hugging Face regardless of endpoint, so a mirror can never corrupt models.',
|
||||
})}
|
||||
control={
|
||||
<>
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[length:var(--text-sm)] text-[color:var(--chrome-fg-muted)]"
|
||||
data-testid="hf-mirror-auto-status"
|
||||
>
|
||||
{autoStatus}
|
||||
</span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leading={<RefreshCw size={13} aria-hidden="true" />}
|
||||
onClick={testAgain}
|
||||
loading={testing}
|
||||
disabled={testing || saving}
|
||||
data-testid="hf-mirror-test"
|
||||
>
|
||||
{testing
|
||||
? t('models.mirror_auto_testing', { defaultValue: 'Testing…' })
|
||||
: t('models.mirror_auto_test', { defaultValue: 'Test again' })}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => save(url)}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
data-testid="hf-mirror-save"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
stack
|
||||
title={t('models.mirror_custom_url', { defaultValue: 'Custom mirror URL' })}
|
||||
note={t('models.mirror_custom_url_note', {
|
||||
defaultValue: 'Sets the HF_ENDPOINT environment variable for Hugging Face downloads.',
|
||||
})}
|
||||
subtitle={restart ? t('models.mirror_restart_note') : undefined}
|
||||
control={
|
||||
<>
|
||||
<SettingsInput
|
||||
mono
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://hf-mirror.com"
|
||||
aria-label={t('models.mirror_custom_url', { defaultValue: 'Custom mirror URL' })}
|
||||
data-testid="hf-mirror-url"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => save(url, 'manual')}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
data-testid="hf-mirror-save"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
apiJson: vi.fn(),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import HFMirrorPanel from './HFMirrorPanel';
|
||||
|
||||
const PRESETS = [
|
||||
{ label: 'Official (huggingface.co)', url: '' },
|
||||
{ label: 'hf-mirror.com (community, China)', url: 'https://hf-mirror.com' },
|
||||
];
|
||||
|
||||
// An existing explicit mirror config — loads as the matching MANUAL mode.
|
||||
const MANUAL_STATE = {
|
||||
configured: 'https://hf-mirror.com',
|
||||
effective: 'https://hf-mirror.com',
|
||||
presets: PRESETS,
|
||||
mode: 'manual',
|
||||
auto: null,
|
||||
auto_opt_out: false,
|
||||
};
|
||||
|
||||
const AUTO_STATE = {
|
||||
configured: '',
|
||||
effective: '',
|
||||
presets: PRESETS,
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
endpoint: 'https://hf-mirror.com',
|
||||
reachable: true,
|
||||
latency_ms: 87.3,
|
||||
checked_at: 1752200000,
|
||||
results: [],
|
||||
},
|
||||
auto_opt_out: false,
|
||||
};
|
||||
|
||||
describe('HFMirrorPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the panel visible with an error and a Retry when the initial GET fails', async () => {
|
||||
// The restricted-network user whose backend GET 500s is exactly the user
|
||||
// who needs this panel — it must never silently vanish.
|
||||
apiJson.mockRejectedValueOnce(new Error('HTTP 500'));
|
||||
|
||||
render(<HFMirrorPanel />);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 500');
|
||||
expect(screen.getByText('Hugging Face mirror')).toBeInTheDocument();
|
||||
|
||||
// Retry re-fetches and renders the rows.
|
||||
apiJson.mockResolvedValueOnce(MANUAL_STATE);
|
||||
fireEvent.click(screen.getByTestId('hf-mirror-retry'));
|
||||
|
||||
expect(await screen.findByTestId('hf-mirror-url')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a loading state while the GET is in flight (never an empty gap)', () => {
|
||||
apiJson.mockReturnValue(new Promise(() => {}));
|
||||
render(<HFMirrorPanel />);
|
||||
expect(screen.getByText('Hugging Face mirror')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('hf-mirror-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads an existing explicit config as the matching manual mode — never Auto', async () => {
|
||||
apiJson.mockResolvedValue(MANUAL_STATE);
|
||||
render(<HFMirrorPanel />);
|
||||
|
||||
const mirror = await screen.findByTestId('hf-preset-https://hf-mirror.com');
|
||||
const official = screen.getByTestId('hf-preset-official');
|
||||
const auto = screen.getByTestId('hf-preset-auto');
|
||||
expect(mirror).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(official).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(auto).toHaveAttribute('aria-pressed', 'false');
|
||||
// Manual mode has no auto-status row.
|
||||
expect(screen.queryByTestId('hf-mirror-auto-status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the custom-URL row in plain language and toasts on save', async () => {
|
||||
apiJson.mockResolvedValue(MANUAL_STATE);
|
||||
apiFetch.mockResolvedValue({
|
||||
json: async () => ({
|
||||
...MANUAL_STATE,
|
||||
configured: 'https://mirror.example',
|
||||
restart_required: true,
|
||||
}),
|
||||
});
|
||||
|
||||
render(<HFMirrorPanel />);
|
||||
|
||||
// Plain translated label (HF_ENDPOINT is a subtitle detail, not the title),
|
||||
// and the input carries an accessible name.
|
||||
const input = await screen.findByLabelText('Custom mirror URL');
|
||||
expect(screen.getByText('Custom mirror URL')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: 'https://mirror.example' } });
|
||||
fireEvent.click(screen.getByTestId('hf-mirror-save'));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Mirror setting saved'));
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
'/api/settings/hf-mirror',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ url: 'https://mirror.example', mode: 'manual' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the Auto pick with measured latency, last-checked, and a Test again button', async () => {
|
||||
apiJson.mockResolvedValue(AUTO_STATE);
|
||||
render(<HFMirrorPanel />);
|
||||
|
||||
const auto = await screen.findByTestId('hf-preset-auto');
|
||||
expect(auto).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const status = screen.getByTestId('hf-mirror-auto-status');
|
||||
expect(status).toHaveTextContent('hf-mirror.com');
|
||||
expect(status).toHaveTextContent('87 ms');
|
||||
expect(status).toHaveTextContent('checked');
|
||||
expect(screen.getByTestId('hf-mirror-test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an honest untested state in Auto mode before the first race', async () => {
|
||||
apiJson.mockResolvedValue({ ...AUTO_STATE, auto: null });
|
||||
render(<HFMirrorPanel />);
|
||||
|
||||
const status = await screen.findByTestId('hf-mirror-auto-status');
|
||||
expect(status).toHaveTextContent('Not tested yet');
|
||||
});
|
||||
|
||||
it('Test again POSTs to the test endpoint and updates the shown pick', async () => {
|
||||
apiJson.mockResolvedValue(AUTO_STATE);
|
||||
apiFetch.mockResolvedValue({
|
||||
json: async () => ({
|
||||
...AUTO_STATE,
|
||||
auto: { ...AUTO_STATE.auto, endpoint: 'https://huggingface.co', latency_ms: 42 },
|
||||
}),
|
||||
});
|
||||
|
||||
render(<HFMirrorPanel />);
|
||||
fireEvent.click(await screen.findByTestId('hf-mirror-test'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/settings/hf-mirror/test', { method: 'POST' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('hf-mirror-auto-status')).toHaveTextContent('huggingface.co'),
|
||||
);
|
||||
});
|
||||
|
||||
it('clicking Auto saves mode=auto; clicking a preset saves an explicit manual pick', async () => {
|
||||
apiJson.mockResolvedValue(MANUAL_STATE);
|
||||
apiFetch.mockResolvedValue({
|
||||
json: async () => ({ ...AUTO_STATE, restart_required: false }),
|
||||
});
|
||||
|
||||
render(<HFMirrorPanel />);
|
||||
fireEvent.click(await screen.findByTestId('hf-preset-auto'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
'/api/settings/hf-mirror',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ url: '', mode: 'auto' }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Panel reflects the returned Auto state (pick row appears).
|
||||
expect(await screen.findByTestId('hf-mirror-auto-status')).toBeInTheDocument();
|
||||
|
||||
apiFetch.mockResolvedValue({
|
||||
json: async () => ({ ...MANUAL_STATE, restart_required: true }),
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('hf-preset-https://hf-mirror.com'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenLastCalledWith(
|
||||
'/api/settings/hf-mirror',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ url: 'https://hf-mirror.com', mode: 'manual' }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { History } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import { Button } from '../../ui';
|
||||
import { SettingsSection, SettingRow, InfoHint } from './primitives';
|
||||
|
||||
export default function HistoryRetentionPanel() {
|
||||
@@ -22,20 +23,36 @@ export default function HistoryRetentionPanel() {
|
||||
const [cap, setCap] = useState('');
|
||||
const [def, setDef] = useState(200);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await apiJson('/api/settings/history-retention');
|
||||
setCap(String(d?.cap ?? ''));
|
||||
if (Number.isInteger(d?.default)) setDef(d.default);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
// Backend older than this panel — leave the default hint in place.
|
||||
if (e?.status === 404) {
|
||||
// Backend older than this panel — leave the default hint in place.
|
||||
setLoaded(true);
|
||||
} else {
|
||||
// Transport failure / 500: the shown default may not be the real cap,
|
||||
// so say so and hold Save until a load succeeds.
|
||||
setError(
|
||||
e?.message ||
|
||||
t('settings.history_retention_load_failed', {
|
||||
defaultValue: 'Could not load the current retention limit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -49,15 +66,12 @@ export default function HistoryRetentionPanel() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
// apiFetch throws ApiError on any non-OK response.
|
||||
const res = await apiFetch('/api/settings/history-retention', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cap: n }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const b = await res.json().catch(() => ({}));
|
||||
throw new Error(b?.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const b = await res.json();
|
||||
setCap(String(b?.cap ?? n));
|
||||
toast.success(
|
||||
@@ -89,6 +103,11 @@ export default function HistoryRetentionPanel() {
|
||||
</InfoHint>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<div className="perfpanel__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
title={t('settings.history_retention_cap', { defaultValue: 'Takes to keep' })}
|
||||
subtitle={t('settings.history_retention_cap_hint', {
|
||||
@@ -105,20 +124,28 @@ export default function HistoryRetentionPanel() {
|
||||
value={cap}
|
||||
placeholder={String(def)}
|
||||
onChange={(e) => setCap(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !saving && !loading && loaded) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}}
|
||||
disabled={saving || loading}
|
||||
aria-label={t('settings.history_retention_cap', { defaultValue: 'Takes to keep' })}
|
||||
data-testid="history-retention-input"
|
||||
/>
|
||||
<button
|
||||
className="flex-none cursor-pointer rounded-[var(--chrome-radius-pill)] [border:1px_solid_transparent] bg-[var(--chrome-accent)] px-[var(--space-4)] py-[var(--space-2)] font-sans text-[length:var(--text-base)] text-[var(--chrome-bg)] disabled:cursor-default disabled:opacity-50"
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={saving || loading}
|
||||
loading={saving}
|
||||
disabled={loading || !loaded}
|
||||
data-testid="history-retention-save"
|
||||
>
|
||||
{saving
|
||||
? t('common.saving', { defaultValue: 'Saving…' })
|
||||
: t('common.save', { defaultValue: 'Save' })}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,43 @@ describe('HistoryRetentionPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('saves on Enter in the input', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ status: 200, body: { cap: 200, default: 200 } }, // initial GET
|
||||
{ status: 200, body: { cap: 75, default: 200 } }, // PUT
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
|
||||
render(<HistoryRetentionPanel />);
|
||||
await waitFor(() => screen.getByTestId('history-retention-input'));
|
||||
const input = screen.getByTestId('history-retention-input');
|
||||
fireEvent.change(input, { target: { value: '75' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
const put = fetchMock.mock.calls.find(([_u, opts]) => opts && opts.method === 'PUT');
|
||||
expect(put).toBeTruthy();
|
||||
expect(JSON.parse(put[1].body)).toEqual({ cap: 75 });
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a load failure (500) and holds Save until a load succeeds', async () => {
|
||||
global.fetch = mockFetchSequence({ status: 500, body: { detail: 'db locked' } });
|
||||
render(<HistoryRetentionPanel />);
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/db locked/);
|
||||
expect(screen.getByTestId('history-retention-save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('stays silent and usable on a 404 (backend older than the panel)', async () => {
|
||||
global.fetch = mockFetchSequence({ status: 404, body: { detail: 'Not Found' } });
|
||||
render(<HistoryRetentionPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('history-retention-save')).not.toBeDisabled());
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
// The hardcoded default hint stays in place.
|
||||
expect(screen.getByTestId('history-retention-input')).toHaveAttribute('placeholder', '200');
|
||||
});
|
||||
|
||||
it('rejects a negative cap client-side without a PUT', async () => {
|
||||
const fetchMock = mockFetchSequence({ status: 200, body: { cap: 200, default: 200 } });
|
||||
global.fetch = fetchMock;
|
||||
|
||||
@@ -32,11 +32,21 @@ function keyEventToAccelerator(e) {
|
||||
return [...mods, key].join('+');
|
||||
}
|
||||
|
||||
// A pure modifier press means the user is still building the chord — stay
|
||||
// quiet. Anything else that fails to produce an accelerator (a bare letter,
|
||||
// F5, Space…) is a real rejection and deserves visible feedback.
|
||||
function isPureModifierEvent(e) {
|
||||
return /^(Meta|Control|Alt|Shift|OS)/.test(e.key || '');
|
||||
}
|
||||
|
||||
export default function HotkeyTab() {
|
||||
const { t } = useTranslation();
|
||||
const [current, setCurrent] = useState('');
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [pending, setPending] = useState('');
|
||||
// True after a modifier-less press while recording — drives the inline
|
||||
// "add a modifier" feedback instead of listening forever in silence.
|
||||
const [rejected, setRejected] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const tauri = isTauri();
|
||||
|
||||
@@ -55,7 +65,9 @@ export default function HotkeyTab() {
|
||||
}, [tauri]);
|
||||
|
||||
// While recording, swallow keystrokes globally and convert the next real
|
||||
// press into an accelerator string. Escape cancels.
|
||||
// press into an accelerator string. Escape cancels; losing window focus
|
||||
// cancels too so a stray click outside doesn't leave a global
|
||||
// key-swallowing listener armed forever.
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
const onKeyDown = (e) => {
|
||||
@@ -64,16 +76,28 @@ export default function HotkeyTab() {
|
||||
if (e.key === 'Escape') {
|
||||
setRecording(false);
|
||||
setPending('');
|
||||
setRejected(false);
|
||||
return;
|
||||
}
|
||||
const accel = keyEventToAccelerator(e);
|
||||
if (accel) {
|
||||
setPending(accel);
|
||||
setRecording(false);
|
||||
setRejected(false);
|
||||
return;
|
||||
}
|
||||
if (!isPureModifierEvent(e)) setRejected(true);
|
||||
};
|
||||
const onBlur = () => {
|
||||
setRecording(false);
|
||||
setRejected(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('blur', onBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
};
|
||||
}, [recording]);
|
||||
|
||||
const save = async () => {
|
||||
@@ -123,7 +147,11 @@ export default function HotkeyTab() {
|
||||
<SettingRow
|
||||
title={recording ? t('capture.press_key') : t('capture.new_shortcut')}
|
||||
hint={<Trans i18nKey="capture.desc_detail" components={{ 1: <code />, 2: <code /> }} />}
|
||||
control={recording ? t('capture.listening') : pending || '—'}
|
||||
control={
|
||||
recording
|
||||
? (rejected && t('capture.needs_modifier')) || t('capture.listening')
|
||||
: pending || '—'
|
||||
}
|
||||
mono
|
||||
/>
|
||||
|
||||
@@ -132,13 +160,16 @@ export default function HotkeyTab() {
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
// Toggle: while recording, the same button cancels (Esc still
|
||||
// works too) — re-clicking must not silently re-arm the recorder.
|
||||
setPending('');
|
||||
setRecording(true);
|
||||
setRejected(false);
|
||||
setRecording(!recording);
|
||||
}}
|
||||
disabled={!tauri || saving}
|
||||
leading={<Keyboard size={12} />}
|
||||
>
|
||||
{recording ? t('capture.recording') : t('capture.record_shortcut')}
|
||||
{recording ? t('common.cancel') : t('capture.record_shortcut')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import HotkeyTab from './HotkeyTab';
|
||||
|
||||
// Recording is only armed in the desktop shell; pretend we are in it and
|
||||
// stub the two shortcut IPC commands.
|
||||
vi.mock('./native', () => ({ isTauri: () => true }));
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(async (cmd) => (cmd === 'get_dictation_shortcut' ? 'CmdOrCtrl+Shift+Space' : '')),
|
||||
}));
|
||||
|
||||
async function startRecording() {
|
||||
render(<HotkeyTab />);
|
||||
// Wait for the mount-time shortcut load so state updates stay inside act().
|
||||
await screen.findByText('CmdOrCtrl+Shift+Space');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Record shortcut' }));
|
||||
expect(screen.getByText(/listening/)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
describe('HotkeyTab — recording feedback and cancel affordances', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('a modifier-less key press shows "add a modifier" feedback instead of silence', async () => {
|
||||
await startRecording();
|
||||
fireEvent.keyDown(window, { key: 'a', code: 'KeyA' });
|
||||
expect(screen.getByText(/Add a modifier/)).toBeInTheDocument();
|
||||
// Still recording — the button stays in its cancel state.
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a pure modifier press (chord in progress) does NOT trigger the rejection message', async () => {
|
||||
await startRecording();
|
||||
fireEvent.keyDown(window, { key: 'Control', code: 'ControlLeft', ctrlKey: true });
|
||||
expect(screen.queryByText(/Add a modifier/)).toBeNull();
|
||||
expect(screen.getByText(/listening/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a modifier+key press captures the accelerator and clears the rejection state', async () => {
|
||||
await startRecording();
|
||||
fireEvent.keyDown(window, { key: 'a', code: 'KeyA' }); // rejected first
|
||||
fireEvent.keyDown(window, { key: 'a', code: 'KeyA', ctrlKey: true });
|
||||
expect(screen.getByText('Ctrl+A')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Add a modifier/)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Record shortcut' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the record button while recording cancels instead of re-arming', async () => {
|
||||
await startRecording();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByText(/listening/)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Record shortcut' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('losing window focus cancels recording (no global key-swallower left armed)', async () => {
|
||||
await startRecording();
|
||||
fireEvent(window, new Event('blur'));
|
||||
expect(screen.queryByText(/listening/)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Record shortcut' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Escape cancels recording', async () => {
|
||||
await startRecording();
|
||||
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' });
|
||||
expect(screen.queryByText(/listening/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -115,8 +115,11 @@ export default function LLMProvidersPanel() {
|
||||
populate(providers, id);
|
||||
};
|
||||
|
||||
// Returns true when the PUT (and refresh) succeeded — Test / Fetch models
|
||||
// gate on it so they never probe the previously-stored config after a
|
||||
// failed save (which could show a green "Test ok" beside a save error).
|
||||
const save = async (makeActive) => {
|
||||
if (!current) return;
|
||||
if (!current) return false;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -138,8 +141,10 @@ export default function LLMProvidersPanel() {
|
||||
// pins the choice — the env banner already explains and the suggested
|
||||
// button is disabled.
|
||||
setSavedInactive(Boolean(data) && data.active !== current.id && !current.active_from_env);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e?.message || t('settings.llmp_save_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -151,8 +156,10 @@ export default function LLMProvidersPanel() {
|
||||
setTest(null);
|
||||
setError(null);
|
||||
try {
|
||||
// Save first so the probe sees the just-typed key/URL.
|
||||
await save(false);
|
||||
// Save first so the probe sees the just-typed key/URL. If the save
|
||||
// failed, stop: probing the stale stored config would contradict the
|
||||
// save error with a misleading green badge.
|
||||
if (!(await save(false))) return;
|
||||
const res = await apiPost(`/api/settings/llm-providers/${current.id}/test`);
|
||||
setTest(res);
|
||||
} catch (e) {
|
||||
@@ -167,8 +174,9 @@ export default function LLMProvidersPanel() {
|
||||
setLoadingModels(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Save non-key fields first so the probe uses the just-typed base URL.
|
||||
await save(false);
|
||||
// Save non-key fields first so the probe uses the just-typed base URL;
|
||||
// abort on a failed save (same stale-config trap as runTest).
|
||||
if (!(await save(false))) return;
|
||||
const res = await apiJson(`/api/settings/llm-providers/${current.id}/models`);
|
||||
if (res.ok) {
|
||||
setModels(res.models || []);
|
||||
@@ -187,6 +195,8 @@ export default function LLMProvidersPanel() {
|
||||
};
|
||||
|
||||
if (!providers.length) {
|
||||
// A failed initial GET used to dead-end here (nothing re-runs refresh
|
||||
// without a remount) — the Retry button is the way back in.
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Brain}
|
||||
@@ -195,7 +205,15 @@ export default function LLMProvidersPanel() {
|
||||
>
|
||||
{error && (
|
||||
<div className="perfpanel__error" role="alert">
|
||||
{error}
|
||||
<span className="mr-[8px]">{error}</span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => refresh()}
|
||||
data-testid="llm-provider-retry"
|
||||
>
|
||||
{t('settings.retry', { defaultValue: 'Retry' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -200,6 +200,53 @@ describe('LLMProvidersPanel', () => {
|
||||
expect(screen.getByText(/not yet used for translation/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a failed implicit save aborts Test — no probe against the stale stored config', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ body: PROVIDERS }, // mount GET
|
||||
{ status: 500, body: { detail: 'disk full' } }, // save PUT fails
|
||||
// nothing else queued: the /test POST must never fire
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
render(<LLMProvidersPanel />);
|
||||
fireEvent.click(await screen.findByTestId('llm-provider-test'));
|
||||
|
||||
// The save error is the single message…
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/disk full/));
|
||||
// …with no contradictory green "Test ok" badge and no /test round-trip.
|
||||
expect(screen.queryByText(/ok —/)).toBeNull();
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/test'))).toBe(false);
|
||||
});
|
||||
|
||||
it('a failed implicit save aborts Fetch models', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ body: PROVIDERS }, // mount GET
|
||||
{ status: 500, body: { detail: 'disk full' } }, // save PUT fails
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
render(<LLMProvidersPanel />);
|
||||
fireEvent.click(await screen.findByTestId('llm-provider-models'));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/disk full/));
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/models'))).toBe(false);
|
||||
});
|
||||
|
||||
it('initial-load failure offers a Retry that refetches (no remount needed)', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ status: 500, body: { detail: 'backend hiccup' } }, // mount GET fails
|
||||
{ body: PROVIDERS }, // retry GET succeeds
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
render(<LLMProvidersPanel />);
|
||||
|
||||
const retry = await screen.findByTestId('llm-provider-retry');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/backend hiccup/);
|
||||
fireEvent.click(retry);
|
||||
|
||||
const select = await screen.findByTestId('llm-provider-select');
|
||||
await waitFor(() => expect(select.value).toBe('groq'));
|
||||
expect(screen.queryByTestId('llm-provider-retry')).toBeNull();
|
||||
});
|
||||
|
||||
it('no notice when the saved provider IS the active one', async () => {
|
||||
global.fetch = mockFetchSequence(
|
||||
{ body: PROVIDERS }, // mount GET (active: groq)
|
||||
|
||||
@@ -136,6 +136,10 @@ export default function LLMSkillsPanel() {
|
||||
value={skill.provider_override || ''}
|
||||
onChange={(e) => update(skill.id, { provider_override: e.target.value })}
|
||||
disabled={!skill.enabled || busy === skill.id}
|
||||
aria-label={t('settings.llmskills_route_for', {
|
||||
defaultValue: 'Provider for {{skill}}',
|
||||
skill: t(skill.name_key),
|
||||
})}
|
||||
data-testid={`llm-skill-provider-${skill.id}`}
|
||||
>
|
||||
<option value="">{t('settings.llmskills_use_active')}</option>
|
||||
|
||||
@@ -121,6 +121,14 @@ describe('LLMSkillsPanel', () => {
|
||||
expect(put.mock.calls[0][1]).toEqual({ provider_override: 'ollama' });
|
||||
});
|
||||
|
||||
it('the per-skill routing Select carries an accessible name', async () => {
|
||||
global.fetch = mockFetch(routes);
|
||||
render(<LLMSkillsPanel />);
|
||||
const select = await screen.findByTestId('llm-skill-provider-cinematic_translation');
|
||||
// Announced as "Provider for <skill>" — not an unlabeled combobox.
|
||||
expect(select).toHaveAccessibleName('Provider for Cinematic & Autofit translation');
|
||||
});
|
||||
|
||||
it('shows the needs-setup badge + LLM Providers link when no provider resolves', async () => {
|
||||
const unready = {
|
||||
skills: SKILLS.skills.map((s) => ({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { FileText, RefreshCw, Trash2, AlertCircle } from 'lucide-react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Copy, FileText, FolderOpen, RefreshCw, Trash2, AlertCircle } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { exportReveal } from '../../api/exports';
|
||||
import { copyText } from '../../utils/copyText';
|
||||
import { Segmented, Button, Badge } from '../../ui';
|
||||
import { SettingsSection } from './primitives';
|
||||
import ReportBugButton from '../ReportBugButton';
|
||||
@@ -21,6 +24,37 @@ export default function LogsTab({
|
||||
onClearLogs,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
// Fresh log loads land scrolled to the newest entries — the tail is the
|
||||
// whole point of checking logs; without this the viewer opens at the oldest
|
||||
// line of the tailed window on every refresh.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [logs]);
|
||||
|
||||
// The frontend "log" is an in-memory buffer — there is no file to reveal.
|
||||
const hasLogFile = logSource !== 'frontend' && !!logMeta.exists && !!logMeta.path;
|
||||
|
||||
const openLogFolder = async () => {
|
||||
try {
|
||||
await exportReveal({ path: logMeta.path });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e?.message || t('settings.open_folder_failed', { defaultValue: 'Could not open folder' }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLogs = async () => {
|
||||
const ok = await copyText(logs.join(''));
|
||||
if (ok) {
|
||||
toast.success(t('logs.log_copied', { source: t(`common.${logSource}`) }));
|
||||
} else {
|
||||
toast.error(t('logs.copy_failed_short', { defaultValue: 'Could not copy the log' }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -29,6 +63,16 @@ export default function LogsTab({
|
||||
actions={
|
||||
<>
|
||||
<ReportBugButton />
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={copyLogs}
|
||||
disabled={logs.length === 0}
|
||||
leading={<Copy size={11} />}
|
||||
data-testid="logs-copy"
|
||||
>
|
||||
{t('logs.copy_visible', { defaultValue: 'Copy visible log' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
@@ -48,17 +92,37 @@ export default function LogsTab({
|
||||
items={LOG_SOURCE_DEFS.map((d) => ({ ...d, label: t(`common.${d.key}`) }))}
|
||||
value={logSource}
|
||||
onChange={setLogSource}
|
||||
aria-label={t('logs.source', { defaultValue: 'Log source' })}
|
||||
/>
|
||||
|
||||
<div className="settings-log-meta flex items-center gap-[var(--space-4)] my-[var(--space-4)] font-mono text-[var(--text-base)] text-[var(--chrome-fg-dim)]">
|
||||
<span>{logMeta.path || '—'}</span>
|
||||
{hasLogFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openLogFolder}
|
||||
leading={<FolderOpen size={11} />}
|
||||
title={logMeta.path}
|
||||
data-testid="logs-open-folder"
|
||||
>
|
||||
{t('settings.storage_open_folder', { defaultValue: 'Open folder' })}
|
||||
</Button>
|
||||
)}
|
||||
{logSource === 'tauri' && !logMeta.exists && (
|
||||
<Badge tone="warn">
|
||||
<AlertCircle size={11} /> {t('logs.no_tauri_log')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-[var(--chrome-bg)] [border:1px_solid_var(--chrome-border)] rounded-[var(--chrome-radius-pill)] px-[12px] py-[10px] max-h-[280px] overflow-auto font-mono text-[0.72rem] text-[var(--chrome-fg-muted)] whitespace-pre-wrap break-words">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
tabIndex={0}
|
||||
role="log"
|
||||
aria-label={t('settings.logs')}
|
||||
data-testid="logs-scroll"
|
||||
className="bg-[var(--chrome-bg)] [border:1px_solid_var(--chrome-border)] rounded-[var(--chrome-radius-pill)] px-[12px] py-[10px] max-h-[280px] overflow-auto font-mono text-[0.72rem] text-[var(--chrome-fg-muted)] whitespace-pre-wrap break-words focus-visible:outline-none focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)]"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<span className="settings-log__empty font-sans text-[var(--chrome-fg-dim)]">
|
||||
{logSource === 'frontend'
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import LogsTab from './LogsTab';
|
||||
|
||||
const LINES = ['[10:00:00] boot\n', '[10:00:01] ready\n'];
|
||||
|
||||
function renderTab(overrides = {}) {
|
||||
const props = {
|
||||
logSource: 'backend',
|
||||
setLogSource: vi.fn(),
|
||||
logs: LINES,
|
||||
logMeta: { path: '/home/u/.omnivoice/omnivoice.log', exists: true },
|
||||
loadingLogs: false,
|
||||
refreshLogs: vi.fn(),
|
||||
onClearLogs: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<LogsTab {...props} />), props };
|
||||
}
|
||||
|
||||
describe('LogsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('offers Open folder for on-disk logs and reveals via /export/reveal', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true }),
|
||||
text: async () => '{"success":true}',
|
||||
});
|
||||
global.fetch = fetchMock;
|
||||
|
||||
renderTab();
|
||||
fireEvent.click(screen.getByTestId('logs-open-folder'));
|
||||
await waitFor(() => {
|
||||
const call = fetchMock.mock.calls.find(([u]) => u.endsWith('/export/reveal'));
|
||||
expect(call).toBeTruthy();
|
||||
expect(JSON.parse(call[1].body)).toEqual({ path: '/home/u/.omnivoice/omnivoice.log' });
|
||||
});
|
||||
});
|
||||
|
||||
it('hides Open folder for the in-memory frontend buffer and missing files', () => {
|
||||
renderTab({ logSource: 'frontend', logMeta: { path: 'in-memory (last 500)', exists: true } });
|
||||
expect(screen.queryByTestId('logs-open-folder')).not.toBeInTheDocument();
|
||||
|
||||
renderTab({ logSource: 'tauri', logMeta: { path: '—', exists: false } });
|
||||
expect(screen.queryByTestId('logs-open-folder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies the visible tail to the clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
|
||||
renderTab();
|
||||
fireEvent.click(screen.getByTestId('logs-copy'));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(LINES.join('')));
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
|
||||
});
|
||||
|
||||
it('disables Copy when there is nothing to copy', () => {
|
||||
renderTab({ logs: [] });
|
||||
expect(screen.getByTestId('logs-copy')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('log viewport is keyboard-reachable and labelled', () => {
|
||||
renderTab();
|
||||
const box = screen.getByTestId('logs-scroll');
|
||||
expect(box).toHaveAttribute('tabindex', '0');
|
||||
expect(box).toHaveAttribute('role', 'log');
|
||||
expect(box).toHaveAccessibleName('Logs');
|
||||
});
|
||||
|
||||
it('scrolls to the newest entries when logs load', () => {
|
||||
const { rerender, props } = renderTab({ logs: [] });
|
||||
const box = screen.getByTestId('logs-scroll');
|
||||
Object.defineProperty(box, 'scrollHeight', { value: 640, configurable: true });
|
||||
rerender(<LogsTab {...props} logs={LINES} />);
|
||||
expect(box.scrollTop).toBe(640);
|
||||
});
|
||||
});
|
||||
@@ -9,19 +9,31 @@
|
||||
* GET /api/mcp/bindings
|
||||
* PUT /api/mcp/bindings {client_id, label?, profile_id?, default_engine?}
|
||||
* DELETE /api/mcp/bindings/{client_id}
|
||||
*
|
||||
* The API's `default_engine` field is intentionally NOT editable here — it is
|
||||
* an MCP-side capability (agents can request an engine per docs/mcp.md); the
|
||||
* panel only manages the voice routing a user actually reasons about.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bot, Trash2 } from 'lucide-react';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import { listProfiles } from '../../api/profiles';
|
||||
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
|
||||
import { askConfirm } from './native';
|
||||
import { SettingsSection, SettingRow, SettingsInput, InfoHint } from './primitives';
|
||||
import { Button, Badge, Select } from '../../ui';
|
||||
|
||||
const MCP_DOCS_URL = 'https://github.com/debpalash/OmniVoice-Studio/blob/main/docs/mcp.md';
|
||||
|
||||
export default function MCPBindingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [bindings, setBindings] = useState([]);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [profileId, setProfileId] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -31,47 +43,92 @@ export default function MCPBindingsPanel() {
|
||||
setBindings(b);
|
||||
setProfiles(p);
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to load MCP bindings');
|
||||
setError(
|
||||
e?.message ||
|
||||
t('settings.mcp_load_failed', { defaultValue: 'Failed to load MCP bindings' }),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const profileName = (id) => profiles.find((p) => p.id === id)?.name || id || '—';
|
||||
const profileName = (id) =>
|
||||
profiles.find((p) => p.id === id)?.name ||
|
||||
id ||
|
||||
t('settings.mcp_default_voice', { defaultValue: 'Default voice' });
|
||||
|
||||
const onAdd = async () => {
|
||||
if (!clientId.trim()) return;
|
||||
if (!clientId.trim() || adding) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch('/api/mcp/bindings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: clientId.trim(), profile_id: profileId || null }),
|
||||
body: JSON.stringify({
|
||||
client_id: clientId.trim(),
|
||||
label: label.trim() || null,
|
||||
profile_id: profileId || null,
|
||||
}),
|
||||
});
|
||||
setClientId('');
|
||||
setLabel('');
|
||||
setProfileId('');
|
||||
refresh();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to save binding');
|
||||
setError(
|
||||
e?.message || t('settings.mcp_save_failed', { defaultValue: 'Failed to save binding' }),
|
||||
);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (cid) => {
|
||||
if (deletingId) return;
|
||||
const confirmed = await askConfirm(
|
||||
t('settings.mcp_delete_confirm', {
|
||||
defaultValue: 'Remove the voice binding for “{{clientId}}”?',
|
||||
clientId: cid,
|
||||
}),
|
||||
t('settings.mcp_delete_confirm_title', { defaultValue: 'Remove binding' }),
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setDeletingId(cid);
|
||||
setError(null);
|
||||
let failure = null;
|
||||
try {
|
||||
await apiFetch(`/api/mcp/bindings/${encodeURIComponent(cid)}`, { method: 'DELETE' });
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to delete binding');
|
||||
failure =
|
||||
e?.message || t('settings.mcp_delete_failed', { defaultValue: 'Failed to delete binding' });
|
||||
}
|
||||
// Re-sync even on failure: a 404 means the row was already gone — the list
|
||||
// must not keep showing it. refresh() clears error state, so re-apply the
|
||||
// delete failure afterwards.
|
||||
await refresh();
|
||||
if (failure) setError(failure);
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Bot}
|
||||
title="MCP voice bindings"
|
||||
description="Bind an agent's client id to a voice profile."
|
||||
title={t('settings.mcp_title', { defaultValue: 'MCP voice bindings' })}
|
||||
description={t('settings.mcp_desc', {
|
||||
defaultValue:
|
||||
'Give each MCP agent its own voice — bind the client id an agent sends to a voice profile.',
|
||||
})}
|
||||
actions={
|
||||
<InfoHint learnMoreHref={MCP_DOCS_URL}>
|
||||
{t('settings.mcp_hint', {
|
||||
defaultValue:
|
||||
'Agents reach OmniVoice at /mcp and identify themselves with a client id (e.g. claude-code). Bind that id to a voice so the agent always speaks in that profile.',
|
||||
})}
|
||||
</InfoHint>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<div className="perfpanel__error" role="alert">
|
||||
@@ -79,16 +136,22 @@ export default function MCPBindingsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bindings.length === 0 && !error && (
|
||||
<p
|
||||
className="m-0 py-[var(--space-3)] text-[length:var(--text-xs)] text-[color:var(--chrome-fg-dim)] leading-[1.5]"
|
||||
data-testid="mcp-empty"
|
||||
>
|
||||
{t('settings.mcp_empty', {
|
||||
defaultValue: "No bindings yet — add an agent's client id below.",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{bindings.map((b) => (
|
||||
<SettingRow
|
||||
key={b.client_id}
|
||||
title={b.label || b.client_id}
|
||||
hint={
|
||||
<>
|
||||
Agents reach OmniVoice at <code>/mcp</code>. Bind an agent's client id to a voice so
|
||||
it speaks in that profile. See <code>docs/mcp.md</code>.
|
||||
</>
|
||||
}
|
||||
subtitle={b.label ? b.client_id : undefined}
|
||||
control={
|
||||
<>
|
||||
<Badge tone="neutral">{profileName(b.profile_id)}</Badge>
|
||||
@@ -96,7 +159,11 @@ export default function MCPBindingsPanel() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(b.client_id)}
|
||||
aria-label={`Remove ${b.client_id}`}
|
||||
disabled={deletingId === b.client_id}
|
||||
aria-label={t('settings.mcp_remove', {
|
||||
defaultValue: 'Remove {{clientId}}',
|
||||
clientId: b.client_id,
|
||||
})}
|
||||
data-testid={`mcp-del-${b.client_id}`}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
@@ -107,31 +174,54 @@ export default function MCPBindingsPanel() {
|
||||
))}
|
||||
|
||||
<SettingRow
|
||||
title="Add binding"
|
||||
title={t('settings.mcp_add_title', { defaultValue: 'Add binding' })}
|
||||
stack
|
||||
control={
|
||||
<>
|
||||
<SettingsInput
|
||||
type="text"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder="client id (e.g. claude-code)"
|
||||
placeholder={t('settings.mcp_client_id_placeholder', {
|
||||
defaultValue: 'Client ID (e.g. claude-code)',
|
||||
})}
|
||||
aria-label={t('settings.mcp_client_id', { defaultValue: 'Client ID' })}
|
||||
data-testid="mcp-client-id"
|
||||
/>
|
||||
<SettingsInput
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('settings.mcp_label_placeholder', {
|
||||
defaultValue: 'Label (optional)',
|
||||
})}
|
||||
aria-label={t('settings.mcp_label', { defaultValue: 'Label' })}
|
||||
data-testid="mcp-label"
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
value={profileId}
|
||||
onChange={(e) => setProfileId(e.target.value)}
|
||||
aria-label={t('settings.mcp_voice_profile', { defaultValue: 'Voice profile' })}
|
||||
data-testid="mcp-profile"
|
||||
>
|
||||
<option value="">default voice</option>
|
||||
<option value="">
|
||||
{t('settings.mcp_default_voice', { defaultValue: 'Default voice' })}
|
||||
</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button variant="subtle" size="sm" onClick={onAdd} data-testid="mcp-add">
|
||||
Bind
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
disabled={!clientId.trim() || adding}
|
||||
data-testid="mcp-add"
|
||||
>
|
||||
{t('settings.mcp_add', { defaultValue: 'Add binding' })}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
// Deterministic confirm: tests flip `confirmAnswer` per case (the real
|
||||
// askConfirm routes through the Tauri dialog plugin / window.confirm).
|
||||
let confirmAnswer = true;
|
||||
const askConfirmMock = vi.fn(async () => confirmAnswer);
|
||||
vi.mock('./native', () => ({
|
||||
isTauri: () => false,
|
||||
askConfirm: (...args) => askConfirmMock(...args),
|
||||
}));
|
||||
|
||||
const PROFILES = [
|
||||
{ id: 'morgan', name: 'Morgan' },
|
||||
{ id: 'scarlett', name: 'Scarlett' },
|
||||
];
|
||||
vi.mock('../../api/profiles', () => ({
|
||||
listProfiles: vi.fn(async () => PROFILES),
|
||||
}));
|
||||
|
||||
import MCPBindingsPanel from './MCPBindingsPanel';
|
||||
|
||||
const BINDINGS = [
|
||||
{ client_id: 'claude-code', label: 'Claude Code', profile_id: 'morgan' },
|
||||
{ client_id: 'cursor', label: null, profile_id: null },
|
||||
];
|
||||
|
||||
function mockFetchSequence(...responses) {
|
||||
const fn = vi.fn();
|
||||
for (const r of responses) {
|
||||
fn.mockResolvedValueOnce({
|
||||
ok: (r.status ?? 200) >= 200 && (r.status ?? 200) < 300,
|
||||
status: r.status ?? 200,
|
||||
json: async () => r.body,
|
||||
text: async () => JSON.stringify(r.body),
|
||||
});
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe('MCPBindingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
confirmAnswer = true;
|
||||
});
|
||||
|
||||
it('renders bindings with label (falling back to client id) and profile badge', async () => {
|
||||
global.fetch = mockFetchSequence({ body: BINDINGS });
|
||||
render(<MCPBindingsPanel />);
|
||||
expect(await screen.findByText('Claude Code')).toBeInTheDocument();
|
||||
// Unlabelled binding falls back to its client id as the row title.
|
||||
expect(screen.getByText('cursor')).toBeInTheDocument();
|
||||
// Profile badge on the bound row ("Morgan" also exists as a select option).
|
||||
expect(screen.getAllByText('Morgan').some((el) => el.tagName !== 'OPTION')).toBe(true);
|
||||
});
|
||||
|
||||
it('empty list shows the first-run guidance instead of a bare add row', async () => {
|
||||
global.fetch = mockFetchSequence({ body: [] });
|
||||
render(<MCPBindingsPanel />);
|
||||
expect(await screen.findByTestId('mcp-empty')).toHaveTextContent(/No bindings yet/);
|
||||
});
|
||||
|
||||
it('load failure surfaces the error (and no stale empty-state hint)', async () => {
|
||||
// HTTP 500 (not a transport error) — apiFetch never retries HTTP errors,
|
||||
// so the test stays fast and deterministic.
|
||||
global.fetch = mockFetchSequence({ status: 500, body: { detail: 'boom' } });
|
||||
render(<MCPBindingsPanel />);
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('mcp-empty')).toBeNull();
|
||||
});
|
||||
|
||||
it('Add binding PUTs client id + optional label + profile, then refreshes', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ body: [] }, // mount GET
|
||||
{ body: { client_id: 'cline', label: 'Cline', profile_id: 'scarlett' } }, // PUT
|
||||
{ body: [{ client_id: 'cline', label: 'Cline', profile_id: 'scarlett' }] }, // refresh GET
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
render(<MCPBindingsPanel />);
|
||||
await screen.findByTestId('mcp-empty');
|
||||
|
||||
fireEvent.change(screen.getByTestId('mcp-client-id'), { target: { value: ' cline ' } });
|
||||
fireEvent.change(screen.getByTestId('mcp-label'), { target: { value: 'Cline' } });
|
||||
fireEvent.change(screen.getByTestId('mcp-profile'), { target: { value: 'scarlett' } });
|
||||
fireEvent.click(screen.getByTestId('mcp-add'));
|
||||
|
||||
await waitFor(() => {
|
||||
const put = fetchMock.mock.calls.find(([, opts]) => opts?.method === 'PUT');
|
||||
expect(put).toBeTruthy();
|
||||
expect(put[0]).toMatch(/\/api\/mcp\/bindings$/);
|
||||
expect(JSON.parse(put[1].body)).toEqual({
|
||||
client_id: 'cline',
|
||||
label: 'Cline',
|
||||
profile_id: 'scarlett',
|
||||
});
|
||||
});
|
||||
// Inputs reset after a successful add; the new row renders.
|
||||
await screen.findByText('Cline');
|
||||
expect(screen.getByTestId('mcp-client-id').value).toBe('');
|
||||
});
|
||||
|
||||
it('Add button is disabled with an empty client id', async () => {
|
||||
global.fetch = mockFetchSequence({ body: [] });
|
||||
render(<MCPBindingsPanel />);
|
||||
await screen.findByTestId('mcp-empty');
|
||||
expect(screen.getByTestId('mcp-add')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('delete asks for confirmation and DELETEs on confirm', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ body: BINDINGS }, // mount GET
|
||||
{ body: { deleted: 'cursor' } }, // DELETE
|
||||
{ body: [BINDINGS[0]] }, // refresh GET
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
render(<MCPBindingsPanel />);
|
||||
fireEvent.click(await screen.findByTestId('mcp-del-cursor'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(askConfirmMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cursor'),
|
||||
expect.any(String),
|
||||
);
|
||||
const del = fetchMock.mock.calls.find(([, opts]) => opts?.method === 'DELETE');
|
||||
expect(del).toBeTruthy();
|
||||
expect(del[0]).toMatch(/\/api\/mcp\/bindings\/cursor$/);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId('mcp-del-cursor')).toBeNull());
|
||||
});
|
||||
|
||||
it('declining the confirmation sends no DELETE', async () => {
|
||||
confirmAnswer = false;
|
||||
const fetchMock = mockFetchSequence({ body: BINDINGS });
|
||||
global.fetch = fetchMock;
|
||||
render(<MCPBindingsPanel />);
|
||||
fireEvent.click(await screen.findByTestId('mcp-del-cursor'));
|
||||
await waitFor(() => expect(askConfirmMock).toHaveBeenCalled());
|
||||
expect(fetchMock.mock.calls.find(([, opts]) => opts?.method === 'DELETE')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a failed delete (already gone: 404) still re-syncs the list', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ body: BINDINGS }, // mount GET
|
||||
{ status: 404, body: { detail: 'No binding for that client id' } }, // DELETE fails
|
||||
{ body: [BINDINGS[0]] }, // refresh GET — row is gone server-side
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
render(<MCPBindingsPanel />);
|
||||
fireEvent.click(await screen.findByTestId('mcp-del-cursor'));
|
||||
// The stale row disappears even though the DELETE errored.
|
||||
await waitFor(() => expect(screen.queryByTestId('mcp-del-cursor')).toBeNull());
|
||||
});
|
||||
|
||||
it('controls carry accessible names', async () => {
|
||||
global.fetch = mockFetchSequence({ body: BINDINGS });
|
||||
render(<MCPBindingsPanel />);
|
||||
await screen.findByText('Claude Code');
|
||||
expect(screen.getByLabelText('Client ID')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Label')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Voice profile')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove cursor' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { openExternal } from '../../api/external';
|
||||
import { setupDownloadStreamUrl } from '../../api/setup';
|
||||
import { listLoadedModels, unloadLoadedModel } from '../../api/system';
|
||||
import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../../api/hooks';
|
||||
import { Button, Segmented } from '../../ui';
|
||||
import { SettingsSection, SettingsInput, SETTINGS_SECTION_SURFACE } from './primitives';
|
||||
@@ -158,10 +159,51 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
return () => clearTimeout(t);
|
||||
}, [rowState, modelsQuery, recoQuery]);
|
||||
|
||||
// Memory residency: repo_id (checkpoint) → its /model/loaded entry. Marks
|
||||
// rows whose weights are resident in RAM/VRAM right now and enables the
|
||||
// Unload affordance where the backend says the entry is unloadable.
|
||||
// Advisory — a fetch failure just means no chips, never a broken tab.
|
||||
const [loadedModels, setLoadedModels] = useState([]);
|
||||
const refreshLoaded = useCallback(async () => {
|
||||
try {
|
||||
const res = await listLoadedModels();
|
||||
setLoadedModels(res?.models || []);
|
||||
} catch {
|
||||
setLoadedModels([]);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
refreshLoaded();
|
||||
}, [refreshLoaded]);
|
||||
const residencyByRepo = useMemo(() => {
|
||||
const map = {};
|
||||
for (const lm of loadedModels) {
|
||||
if (lm?.checkpoint) map[lm.checkpoint] = lm;
|
||||
}
|
||||
return map;
|
||||
}, [loadedModels]);
|
||||
const getResidency = useCallback((m) => residencyByRepo[m.repo_id] || null, [residencyByRepo]);
|
||||
const onUnload = useCallback(
|
||||
async (repoId) => {
|
||||
const entry = residencyByRepo[repoId];
|
||||
if (!entry) return;
|
||||
try {
|
||||
await unloadLoadedModel(entry.id);
|
||||
toast.success(t('models.unloaded_toast'));
|
||||
} catch (e) {
|
||||
toast.error(t('models.unload_failed', { message: e.message || String(e) }));
|
||||
} finally {
|
||||
refreshLoaded();
|
||||
}
|
||||
},
|
||||
[residencyByRepo, refreshLoaded, t],
|
||||
);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
modelsQuery.refetch();
|
||||
recoQuery.refetch();
|
||||
}, [modelsQuery, recoQuery]);
|
||||
refreshLoaded();
|
||||
}, [modelsQuery, recoQuery, refreshLoaded]);
|
||||
|
||||
const withBusy = useCallback(async (repoId, fn, successMsg) => {
|
||||
setBusy((prev) => new Set(prev).add(repoId));
|
||||
@@ -311,6 +353,8 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
onReinstall,
|
||||
onCancel,
|
||||
onDismissError,
|
||||
getResidency,
|
||||
onUnload,
|
||||
}),
|
||||
[
|
||||
getRowRuntime,
|
||||
@@ -319,6 +363,8 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
onReinstall,
|
||||
onCancel,
|
||||
onDismissError,
|
||||
getResidency,
|
||||
onUnload,
|
||||
MODEL_ROLE_LABEL,
|
||||
t,
|
||||
],
|
||||
@@ -355,7 +401,9 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tableRows.length,
|
||||
getScrollElement: () => tableBodyRef.current,
|
||||
estimateSize: () => 68,
|
||||
// Matches the compact two-line .models-row min-height (52px) — rows with
|
||||
// a live progress/error block re-measure and grow past this.
|
||||
estimateSize: () => 54,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
@@ -483,6 +531,7 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
installingReco={installingReco}
|
||||
setInstallingReco={setInstallingReco}
|
||||
onInstallRecommended={onInstallRecommended}
|
||||
diskFreeGb={data.disk_free_gb}
|
||||
/>
|
||||
|
||||
<div className="my-[var(--space-2)] flex items-center gap-[var(--space-2)] max-[580px]:flex-col max-[580px]:items-stretch">
|
||||
@@ -522,6 +571,10 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
tableBodyRef={tableBodyRef}
|
||||
getRowRuntime={getRowRuntime}
|
||||
t={t}
|
||||
onClearFilters={() => {
|
||||
setQuery('');
|
||||
setActiveRole('all');
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* Settings → Network.
|
||||
*
|
||||
* The proxy + FFmpeg-path controls that used to live in GeneralTab's "Advanced"
|
||||
* collapsible, promoted to their own top-level category. Logic is unchanged —
|
||||
* both persist via the backend `/system/set-env` durable env writer and
|
||||
* invalidate the systemInfo query so badges refresh.
|
||||
*
|
||||
* FFmpeg takes effect on the next backend start (durable env), so it carries a
|
||||
* RestartBadge; the proxy applies to subsequent downloads immediately.
|
||||
* Proxy only. The FFmpeg-path override that used to share this panel moved to
|
||||
* Settings → Audio tools (same backend store — prefs `env.FFMPEG_PATH` via
|
||||
* `/media-tools` — richer controls: version, origin, restore bundled); a
|
||||
* pointer row below deep-links there so muscle memory still lands.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Wifi, Globe, Film } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useSystemInfo, queryKeys } from '../../api/hooks';
|
||||
import { Button, Badge } from '../../ui';
|
||||
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
|
||||
@@ -24,41 +22,21 @@ export default function NetworkTab() {
|
||||
const { data: sysInfo } = useSystemInfo();
|
||||
const [proxyUrl, setProxyUrl] = useState('');
|
||||
const [proxySaved, setProxySaved] = useState(false);
|
||||
const [proxyCleared, setProxyCleared] = useState(false);
|
||||
const [proxySaving, setProxySaving] = useState(false);
|
||||
const [ffmpegPath, setFfmpegPath] = useState('');
|
||||
const [ffmpegSaving, setFfmpegSaving] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (!proxyUrl && !proxySaved) setProxyUrl(sysInfo?.proxy_url || '');
|
||||
if (!proxyUrl && !proxySaved && !proxyCleared) setProxyUrl(sysInfo?.proxy_url || '');
|
||||
}, [sysInfo?.proxy_url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ffmpegPath) setFfmpegPath(sysInfo?.ffmpeg_path || '');
|
||||
}, [sysInfo?.ffmpeg_path]);
|
||||
|
||||
const ffmpegOk = sysInfo?.ffmpeg_ok;
|
||||
const ffmpegCurrent = sysInfo?.ffmpeg_path;
|
||||
|
||||
const saveFfmpeg = async () => {
|
||||
const value = ffmpegPath.trim();
|
||||
setFfmpegSaving(true);
|
||||
try {
|
||||
const { apiFetch } = await import('../../api/client');
|
||||
await apiFetch('/system/set-env', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: 'FFMPEG_PATH', value }),
|
||||
});
|
||||
toast.success(t('settings.ffmpeg_saved'));
|
||||
setFfmpegPath('');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
|
||||
} catch (e) {
|
||||
toast.error(t('settings.save_failed', { message: e.message }));
|
||||
} finally {
|
||||
setFfmpegSaving(false);
|
||||
}
|
||||
};
|
||||
// "A proxy is configured" must survive an app reload: derive it from the
|
||||
// backend-persisted value, not only from a save in this session — otherwise
|
||||
// the Clear button (and the "Set" badge) vanish on reload with the proxy
|
||||
// still active and no way to remove it.
|
||||
const proxyConfigured = !proxyCleared && (proxySaved || Boolean(sysInfo?.proxy_url));
|
||||
|
||||
const saveProxy = async () => {
|
||||
const value = proxyUrl.trim();
|
||||
@@ -81,6 +59,7 @@ export default function NetworkTab() {
|
||||
]);
|
||||
toast.success(t('settings.proxy_saved'));
|
||||
setProxySaved(true);
|
||||
setProxyCleared(false);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
|
||||
} catch (e) {
|
||||
toast.error(t('settings.save_failed', { message: e.message }));
|
||||
@@ -109,6 +88,7 @@ export default function NetworkTab() {
|
||||
]);
|
||||
setProxyUrl('');
|
||||
setProxySaved(false);
|
||||
setProxyCleared(true);
|
||||
toast.success(t('settings.proxy_cleared'));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
|
||||
} catch (e) {
|
||||
@@ -123,7 +103,7 @@ export default function NetworkTab() {
|
||||
icon={Wifi}
|
||||
title={t('settings.network', { defaultValue: 'Network' })}
|
||||
description={t('settings.network_desc', {
|
||||
defaultValue: 'Proxy and FFmpeg paths for downloads and media processing.',
|
||||
defaultValue: 'Proxy for downloads and model fetches.',
|
||||
})}
|
||||
>
|
||||
<SettingRow
|
||||
@@ -133,7 +113,8 @@ export default function NetworkTab() {
|
||||
title={
|
||||
<>
|
||||
{t('settings.proxy')}
|
||||
{proxySaved && (
|
||||
<RestartBadge applies />
|
||||
{proxyConfigured && (
|
||||
<Badge tone="success" size="xs">
|
||||
{t('credentials.saved')}
|
||||
</Badge>
|
||||
@@ -148,6 +129,7 @@ export default function NetworkTab() {
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && saveProxy()}
|
||||
aria-label={t('settings.proxy_input_aria', { defaultValue: 'Proxy URL' })}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -158,8 +140,14 @@ export default function NetworkTab() {
|
||||
>
|
||||
{t('credentials.save')}
|
||||
</Button>
|
||||
{proxySaved && (
|
||||
<Button size="sm" variant="ghost" onClick={clearProxy} loading={proxySaving}>
|
||||
{proxyConfigured && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={clearProxy}
|
||||
loading={proxySaving}
|
||||
data-testid="proxy-clear"
|
||||
>
|
||||
{t('settings.proxy_clear')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -167,9 +155,9 @@ export default function NetworkTab() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Pointer, not a control — the FFmpeg override lives in Audio tools now.
|
||||
Two competing writers of env.FFMPEG_PATH would fight each other. */}
|
||||
<SettingRow
|
||||
align="start"
|
||||
stack
|
||||
icon={Film}
|
||||
title={
|
||||
<>
|
||||
@@ -177,32 +165,21 @@ export default function NetworkTab() {
|
||||
<Badge tone={ffmpegOk ? 'success' : 'warn'} size="xs">
|
||||
{ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')}
|
||||
</Badge>
|
||||
<RestartBadge />
|
||||
</>
|
||||
}
|
||||
note={
|
||||
ffmpegCurrent
|
||||
? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}`
|
||||
: t('settings.ffmpeg_desc')
|
||||
}
|
||||
note={t('settings.audio_tools_moved_note', {
|
||||
defaultValue:
|
||||
'The FFmpeg override moved to its own panel with more control (version, origin, restore).',
|
||||
})}
|
||||
control={
|
||||
<>
|
||||
<SettingsInput
|
||||
placeholder="D:\ffmpeg\bin\ffmpeg.exe"
|
||||
value={ffmpegPath}
|
||||
onChange={(e) => setFfmpegPath(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && saveFfmpeg()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={saveFfmpeg}
|
||||
loading={ffmpegSaving}
|
||||
disabled={!ffmpegPath.trim()}
|
||||
>
|
||||
{t('credentials.save')}
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openSettingsTab('audio-tools')}
|
||||
data-testid="open-audio-tools"
|
||||
>
|
||||
{t('settings.audio_tools_open', { defaultValue: 'Open Audio tools' })} →
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Keep toast side-channels out of the test (timers, portals).
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/hooks', () => ({
|
||||
useSystemInfo: vi.fn(),
|
||||
queryKeys: { systemInfo: ['system-info'] },
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
apiFetch: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const { openSettingsTab } = vi.hoisted(() => ({ openSettingsTab: vi.fn() }));
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector) => selector({ openSettingsTab }),
|
||||
}));
|
||||
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useSystemInfo } from '../../api/hooks';
|
||||
import { apiFetch } from '../../api/client';
|
||||
import NetworkTab from './NetworkTab';
|
||||
|
||||
describe('NetworkTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiFetch.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('offers Clear for a proxy persisted in a previous session (after reload)', async () => {
|
||||
// Fresh mount, nothing saved this session — the persisted proxy comes
|
||||
// from the backend. The Clear affordance must NOT depend on having just
|
||||
// clicked Save in the current session.
|
||||
useSystemInfo.mockReturnValue({ data: { proxy_url: 'http://127.0.0.1:7890' } });
|
||||
|
||||
render(<NetworkTab />);
|
||||
|
||||
// Input is prefilled from the persisted value, the "Set" badge shows,
|
||||
// and Clear is available immediately.
|
||||
expect(screen.getByLabelText('Proxy URL')).toHaveValue('http://127.0.0.1:7890');
|
||||
expect(screen.getByText('✓ Set')).toBeInTheDocument();
|
||||
const clear = screen.getByTestId('proxy-clear');
|
||||
|
||||
fireEvent.click(clear);
|
||||
|
||||
await waitFor(() => {
|
||||
// All six proxy env vars are cleared on the backend.
|
||||
const clearedKeys = apiFetch.mock.calls
|
||||
.filter(([path]) => path === '/system/set-env')
|
||||
.map(([, opts]) => JSON.parse(opts.body))
|
||||
.filter((b) => b.value === '')
|
||||
.map((b) => b.key)
|
||||
.sort();
|
||||
expect(clearedKeys).toEqual([
|
||||
'ALL_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'HTTP_PROXY',
|
||||
'all_proxy',
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
]);
|
||||
});
|
||||
|
||||
// The UI reflects the cleared state without waiting for a refetch.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('proxy-clear')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText('Proxy URL')).toHaveValue('');
|
||||
expect(screen.queryByText('✓ Set')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Clear when no proxy is configured', () => {
|
||||
useSystemInfo.mockReturnValue({ data: { proxy_url: '' } });
|
||||
render(<NetworkTab />);
|
||||
expect(screen.queryByTestId('proxy-clear')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('✓ Set')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Clear (and the badge) right after saving in this session', async () => {
|
||||
useSystemInfo.mockReturnValue({ data: { proxy_url: '' } });
|
||||
render(<NetworkTab />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Proxy URL'), {
|
||||
target: { value: 'socks5://127.0.0.1:7890' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.getByTestId('proxy-clear')).toBeInTheDocument();
|
||||
expect(screen.getByText('✓ Set')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the proxy input for assistive tech', () => {
|
||||
useSystemInfo.mockReturnValue({ data: {} });
|
||||
render(<NetworkTab />);
|
||||
expect(screen.getByLabelText('Proxy URL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has NO FFmpeg path control anymore — only the pointer to Audio tools', () => {
|
||||
// The override moved to Settings → Audio tools; a second writer of
|
||||
// env.FFMPEG_PATH here would fight the new panel.
|
||||
useSystemInfo.mockReturnValue({ data: { ffmpeg_ok: true } });
|
||||
render(<NetworkTab />);
|
||||
expect(screen.queryByLabelText('FFmpeg path')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('open-audio-tools'));
|
||||
expect(openSettingsTab).toHaveBeenCalledWith('audio-tools');
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,17 @@ export default function OpenApiPanel() {
|
||||
|
||||
const copyUrl = useCallback(async () => {
|
||||
const ok = await copyText(specUrl);
|
||||
if (ok) toast.success(t('openapi.copied', { defaultValue: 'Spec URL copied' }));
|
||||
if (ok) {
|
||||
toast.success(t('openapi.copied', { defaultValue: 'Spec URL copied' }));
|
||||
} else {
|
||||
// copyText returns false when both clipboard paths fail (e.g. a
|
||||
// non-secure LAN-share context) — never leave the click unanswered.
|
||||
toast.error(
|
||||
t('openapi.copy_failed', {
|
||||
defaultValue: 'Copy failed — select and copy the URL above manually.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [specUrl, t]);
|
||||
|
||||
const openRaw = useCallback(() => {
|
||||
|
||||
@@ -15,8 +15,17 @@ vi.mock('../../api/client', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
// Clipboard helper + toast — controlled so the copy affordance's success AND
|
||||
// failure feedback can both be asserted.
|
||||
vi.mock('../../utils/copyText', () => ({ copyText: vi.fn() }));
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import OpenApiPanel from './OpenApiPanel';
|
||||
import { apiFetch } from '../../api/client';
|
||||
import { copyText } from '../../utils/copyText';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const MINIMAL_SPEC = {
|
||||
openapi: '3.1.0',
|
||||
@@ -73,4 +82,28 @@ describe('OpenApiPanel', () => {
|
||||
expect(await screen.findByTestId('scalar-mock')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('openapi-unreachable')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toasts success when the spec URL copies', async () => {
|
||||
apiFetch.mockResolvedValue({ json: async () => MINIMAL_SPEC });
|
||||
copyText.mockResolvedValue(true);
|
||||
|
||||
render(<OpenApiPanel />);
|
||||
fireEvent.click(screen.getByTestId('openapi-copy-url'));
|
||||
|
||||
await vi.waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(copyText).toHaveBeenCalledWith('http://127.0.0.1:3900/openapi.json');
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toasts an error when the clipboard copy fails (non-secure context)', async () => {
|
||||
apiFetch.mockResolvedValue({ json: async () => MINIMAL_SPEC });
|
||||
copyText.mockResolvedValue(false);
|
||||
|
||||
render(<OpenApiPanel />);
|
||||
fireEvent.click(screen.getByTestId('openapi-copy-url'));
|
||||
|
||||
// A failed copy must never be silent.
|
||||
await vi.waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,13 @@ export default function PerformanceDeviceTab() {
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{status?.status || 'unknown'}
|
||||
{status?.status === 'ready'
|
||||
? t('models.ready_badge')
|
||||
: status?.status === 'loading'
|
||||
? t('models.loading_badge')
|
||||
: status?.status === 'idle'
|
||||
? t('models.idle_badge')
|
||||
: status?.status || t('common.unknown', { defaultValue: 'unknown' })}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
|
||||
import RestartBadge from './RestartBadge';
|
||||
|
||||
export default function PerformancePanel() {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [platform, setPlatform] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -36,11 +38,14 @@ export default function PerformancePanel() {
|
||||
setEnabled(Boolean(data?.enabled));
|
||||
setPlatform(data?.platform ?? null);
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to load performance settings');
|
||||
setError(
|
||||
e?.message ||
|
||||
t('settings.perf_load_failed', { defaultValue: 'Failed to load performance settings' }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -60,7 +65,9 @@ export default function PerformancePanel() {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setEnabled(Boolean(body?.enabled ?? next));
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to save setting');
|
||||
setError(
|
||||
err?.message || t('settings.perf_save_failed', { defaultValue: 'Failed to save setting' }),
|
||||
);
|
||||
// Re-sync on failure so the UI doesn't show a stale state
|
||||
refresh();
|
||||
} finally {
|
||||
@@ -68,8 +75,12 @@ export default function PerformancePanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLabel = t('settings.perf_torch_compile', {
|
||||
defaultValue: 'Disable torch.compile (Windows)',
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsSection icon={Cpu} title="Performance">
|
||||
<SettingsSection icon={Cpu} title={t('settings.perf_title', { defaultValue: 'Performance' })}>
|
||||
{error && (
|
||||
<div className="perfpanel__error" role="alert">
|
||||
{error}
|
||||
@@ -79,33 +90,49 @@ export default function PerformancePanel() {
|
||||
<SettingRow
|
||||
title={
|
||||
<>
|
||||
Disable torch.compile (Windows)
|
||||
{toggleLabel}
|
||||
<RestartBadge />
|
||||
</>
|
||||
}
|
||||
subtitle={!isWindows ? (platform === null ? '…' : 'not applicable') : undefined}
|
||||
note={isWindows ? 'Falls back to eager mode — fixes Triton OOM on <16 GB GPUs.' : undefined}
|
||||
subtitle={
|
||||
!isWindows
|
||||
? platform === null
|
||||
? '…'
|
||||
: t('settings.perf_torch_compile_na', {
|
||||
defaultValue: 'Windows only — not needed on this platform',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
note={
|
||||
isWindows
|
||||
? t('settings.perf_torch_compile_note', {
|
||||
defaultValue: 'Falls back to eager mode — fixes Triton OOM on <16 GB GPUs.',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
hint={
|
||||
<>
|
||||
Workaround for{' '}
|
||||
<a
|
||||
href="https://github.com/debpalash/OmniVoice-Studio/issues/65"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
#65
|
||||
</a>{' '}
|
||||
— Windows users may hit Triton / <code>torch.compile</code> OOM during model load on
|
||||
GPUs with <16 GB VRAM. Enabling this sets <code>TORCH_COMPILE_DISABLE=1</code> on
|
||||
engine subprocesses, which falls back to eager mode. macOS and Linux are unaffected.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey="settings.perf_torch_compile_hint"
|
||||
defaults="Workaround for <issueLink>#65</issueLink> — Windows users may hit Triton / <code>torch.compile</code> OOM during model load on GPUs with less than 16 GB VRAM. Enabling this sets <code>TORCH_COMPILE_DISABLE=1</code> on engine subprocesses, which falls back to eager mode. macOS and Linux are unaffected."
|
||||
components={{
|
||||
// Trans injects the link text ("#65") from the translation string.
|
||||
issueLink: (
|
||||
<a
|
||||
href="https://github.com/debpalash/OmniVoice-Studio/issues/65"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
code: <code />,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
control={
|
||||
<SettingsToggle
|
||||
checked={enabled}
|
||||
onChange={onToggle}
|
||||
disabled={!isWindows || saving || loading}
|
||||
aria-label="Disable torch.compile (Windows)"
|
||||
aria-label={toggleLabel}
|
||||
data-testid="torch-compile-toggle"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -65,7 +65,30 @@ describe('PerformancePanel', () => {
|
||||
const toggle = screen.getByTestId('torch-compile-toggle');
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByText(/not applicable/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/not needed on this platform/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders every user-facing string through i18n (en fallback)', async () => {
|
||||
global.fetch = mockFetchSequence({
|
||||
status: 200,
|
||||
body: { enabled: false, platform: 'win32' },
|
||||
});
|
||||
render(<PerformancePanel />);
|
||||
await waitFor(() => screen.getByTestId('torch-compile-toggle'));
|
||||
// Section title + row label resolve from settings.perf_* keys.
|
||||
expect(screen.getByText('Performance')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Disable torch\.compile \(Windows\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Falls back to eager mode/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('torch-compile-toggle')).toHaveAccessibleName(
|
||||
/Disable torch\.compile \(Windows\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a translated load error when the GET fails', async () => {
|
||||
global.fetch = mockFetchSequence({ status: 500, body: { detail: 'boom' } });
|
||||
render(<PerformancePanel />);
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/boom|Failed to load/i);
|
||||
});
|
||||
|
||||
it('renders disabled on linux platform', async () => {
|
||||
|
||||
@@ -1,12 +1,55 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Badge } from '../../ui';
|
||||
import { Badge, Button } from '../../ui';
|
||||
import { useAppStore } from '../../store';
|
||||
import { SettingsSection } from './primitives';
|
||||
import Row from './Row';
|
||||
|
||||
// Providers that send dialogue text to a third-party service vs. the ones that
|
||||
// run fully on-device (backend/api/routers/dub_translate.py). Anything else —
|
||||
// including the backend's safe-defaults value 'unknown' or a missing
|
||||
// system-info payload — must NOT get the confident green "offline" claim.
|
||||
const ONLINE_PROVIDERS = ['google', 'deepl', 'mymemory', 'microsoft', 'openai'];
|
||||
const OFFLINE_PROVIDERS = ['nllb', 'argos', 'libretranslate'];
|
||||
|
||||
export default function PrivacyTab({ info }) {
|
||||
const { t } = useTranslation();
|
||||
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
|
||||
const provider = info?.translate_provider;
|
||||
|
||||
let translatorBadge;
|
||||
if (provider && ONLINE_PROVIDERS.includes(provider)) {
|
||||
translatorBadge = (
|
||||
<span className="inline-flex items-center gap-[var(--space-2)]">
|
||||
<Badge tone="warn">
|
||||
<AlertCircle size={11} /> {t('privacy.translator_online', { provider })}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openSettingsTab('translation')}
|
||||
data-testid="privacy-change-translator"
|
||||
>
|
||||
{t('privacy.change_translator', { defaultValue: 'Change translator' })}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
} else if (provider && OFFLINE_PROVIDERS.includes(provider)) {
|
||||
translatorBadge = (
|
||||
<Badge tone="success">
|
||||
<CheckCircle size={11} /> {t('privacy.translator_offline')}
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
// Backend down, errored (translate_provider: 'unknown'), or an
|
||||
// unrecognized provider — don't render a privacy assurance without data.
|
||||
translatorBadge = (
|
||||
<Badge tone="neutral" data-testid="privacy-translator-unknown">
|
||||
{t('privacy.translator_unknown', { defaultValue: 'Unknown' })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection icon={ShieldCheck} title={t('settings.privacy')}>
|
||||
@@ -23,24 +66,7 @@ export default function PrivacyTab({ info }) {
|
||||
label={t('privacy.gen_history')}
|
||||
value={<Badge tone="neutral">{t('privacy.local_sqlite')}</Badge>}
|
||||
/>
|
||||
<Row
|
||||
label={t('privacy.network_calls')}
|
||||
value={
|
||||
info?.translate_provider &&
|
||||
['google', 'deepl', 'mymemory', 'microsoft', 'openai'].includes(
|
||||
info.translate_provider,
|
||||
) ? (
|
||||
<Badge tone="warn">
|
||||
<AlertCircle size={11} />{' '}
|
||||
{t('privacy.translator_online', { provider: info.translate_provider })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="success">
|
||||
<CheckCircle size={11} /> {t('privacy.translator_offline')}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Row label={t('privacy.network_calls')} value={translatorBadge} />
|
||||
<Row
|
||||
label={t('privacy.model_telemetry')}
|
||||
value={
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
// Mock the zustand store for the openSettingsTab deep-link action.
|
||||
const { openSettingsTab } = vi.hoisted(() => ({ openSettingsTab: vi.fn() }));
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector) => selector({ openSettingsTab }),
|
||||
}));
|
||||
|
||||
import PrivacyTab from './PrivacyTab';
|
||||
|
||||
describe('PrivacyTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not claim "Offline translator" when system info is missing (backend down)', () => {
|
||||
render(<PrivacyTab info={undefined} />);
|
||||
expect(screen.getByTestId('privacy-translator-unknown')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Offline translator')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not claim \"Offline translator\" for the backend's safe-defaults 'unknown'", () => {
|
||||
render(<PrivacyTab info={{ translate_provider: 'unknown' }} />);
|
||||
expect(screen.getByTestId('privacy-translator-unknown')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Offline translator')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the green badge only for confirmed-offline providers', () => {
|
||||
render(<PrivacyTab info={{ translate_provider: 'nllb' }} />);
|
||||
expect(screen.getByText('Offline translator')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('privacy-translator-unknown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns for online providers and deep-links to Translation settings', () => {
|
||||
render(<PrivacyTab info={{ translate_provider: 'google' }} />);
|
||||
expect(screen.getByText('Translator is online: google')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('privacy-change-translator'));
|
||||
expect(openSettingsTab).toHaveBeenCalledWith('translation');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@
|
||||
* A table of user pronunciation entries (term → respelling), scoped Global or to
|
||||
* a language. Entries are applied as pure text substitution before synthesis, so
|
||||
* a saved entry changes the audio on every engine. Plus a model-free "test"
|
||||
* field that previews the substitution via POST /pronunciation/test.
|
||||
* field that previews the substitution via POST /pronunciation/test — with a
|
||||
* language selector, since language-scoped entries only apply when the request
|
||||
* carries that language. Preview requests are debounced and sequence-guarded so
|
||||
* a slow earlier response can never overwrite a newer one, and the preview
|
||||
* re-runs after any add/toggle/delete/import so it never shows a stale result.
|
||||
* Backup & restore round-trips GET /pronunciation/export ↔ POST /pronunciation/import.
|
||||
*
|
||||
* Endpoints (loopback-only):
|
||||
* GET /pronunciation
|
||||
@@ -12,18 +17,34 @@
|
||||
* PUT /pronunciation/{id} (partial)
|
||||
* DELETE /pronunciation/{id}
|
||||
* POST /pronunciation/test {text, language} → {substituted, changed}
|
||||
* GET /pronunciation/export → {entries: [...]}
|
||||
* POST /pronunciation/import {entries, replace}
|
||||
*
|
||||
* Cross-platform: identical on macOS / Windows / Linux — it's a pure form over
|
||||
* a text transform, no OS-specific behavior. All strings via i18n.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { BookA, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import { askConfirm } from './native';
|
||||
import { SettingsSection, SettingRow, SettingsInput, SettingsToggle } from './primitives';
|
||||
import { Button, Badge, Select } from '../../ui';
|
||||
|
||||
const TYPES = ['respelling', 'ipa', 'cmu'];
|
||||
const TEST_DEBOUNCE_MS = 250;
|
||||
|
||||
// Trigger a browser download for a Blob (same pattern as StoriesEditor).
|
||||
function downloadBlob(blob, filename, doc = document, urlApi = URL) {
|
||||
const url = urlApi.createObjectURL(blob);
|
||||
const a = doc.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
doc.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => urlApi.revokeObjectURL(url), 10000);
|
||||
}
|
||||
|
||||
export default function PronunciationPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,8 +54,17 @@ export default function PronunciationPanel() {
|
||||
const [language, setLanguage] = useState('');
|
||||
const [type, setType] = useState('respelling');
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const [testText, setTestText] = useState('');
|
||||
const [testLang, setTestLang] = useState('*');
|
||||
const [testOut, setTestOut] = useState(null);
|
||||
const [testError, setTestError] = useState(false);
|
||||
|
||||
// Preview sequencing: per-keystroke POSTs can resolve out of order, so each
|
||||
// request takes a ticket and only the latest one may write the result.
|
||||
const testSeq = useRef(0);
|
||||
const testTimer = useRef(null);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
@@ -49,6 +79,80 @@ export default function PronunciationPanel() {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (testTimer.current) clearTimeout(testTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const runTest = useCallback(async (text, lang) => {
|
||||
// A direct run supersedes any pending debounced run (e.g. the user picked a
|
||||
// preview language while a keystroke's timer was still counting down).
|
||||
if (testTimer.current) {
|
||||
clearTimeout(testTimer.current);
|
||||
testTimer.current = null;
|
||||
}
|
||||
const seq = ++testSeq.current;
|
||||
if (!text.trim()) {
|
||||
setTestOut(null);
|
||||
setTestError(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await apiJson('/pronunciation/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
...(lang && lang !== '*' ? { language: lang } : {}),
|
||||
}),
|
||||
});
|
||||
if (seq === testSeq.current) {
|
||||
setTestOut(r);
|
||||
setTestError(false);
|
||||
}
|
||||
} catch {
|
||||
if (seq === testSeq.current) {
|
||||
setTestOut(null);
|
||||
setTestError(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleTest = useCallback(
|
||||
(text, lang) => {
|
||||
if (testTimer.current) clearTimeout(testTimer.current);
|
||||
testSeq.current += 1; // invalidate any in-flight response
|
||||
if (!text.trim()) {
|
||||
setTestOut(null);
|
||||
setTestError(false);
|
||||
return;
|
||||
}
|
||||
testTimer.current = setTimeout(() => runTest(text, lang), TEST_DEBOUNCE_MS);
|
||||
},
|
||||
[runTest],
|
||||
);
|
||||
|
||||
const onTestTextChange = (value) => {
|
||||
setTestText(value);
|
||||
scheduleTest(value, testLang);
|
||||
};
|
||||
|
||||
const onTestLangChange = (value) => {
|
||||
setTestLang(value);
|
||||
if (testText.trim()) runTest(testText, value);
|
||||
};
|
||||
|
||||
// After a successful mutation the dictionary changed — re-run the preview so
|
||||
// it reflects the new state instead of going stale.
|
||||
const retest = useCallback(
|
||||
(lang = testLang) => {
|
||||
if (testText.trim()) runTest(testText, lang);
|
||||
},
|
||||
[runTest, testText, testLang],
|
||||
);
|
||||
|
||||
const onAdd = async () => {
|
||||
if (!term.trim()) return;
|
||||
setError(null);
|
||||
@@ -69,11 +173,19 @@ export default function PronunciationPanel() {
|
||||
setLanguage('');
|
||||
setType('respelling');
|
||||
refresh();
|
||||
retest();
|
||||
} catch (e) {
|
||||
setError(e?.message || t('pronunciation.save_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const onAddKeyDown = (ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
onAdd();
|
||||
}
|
||||
};
|
||||
|
||||
const onToggle = async (entry) => {
|
||||
try {
|
||||
await apiFetch(`/pronunciation/${encodeURIComponent(entry.id)}`, {
|
||||
@@ -82,6 +194,7 @@ export default function PronunciationPanel() {
|
||||
body: JSON.stringify({ enabled: !entry.enabled }),
|
||||
});
|
||||
refresh();
|
||||
retest();
|
||||
} catch (e) {
|
||||
setError(e?.message || t('pronunciation.save_error'));
|
||||
}
|
||||
@@ -91,32 +204,73 @@ export default function PronunciationPanel() {
|
||||
try {
|
||||
await apiFetch(`/pronunciation/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
refresh();
|
||||
retest();
|
||||
} catch (e) {
|
||||
setError(e?.message || t('pronunciation.save_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const onTest = async (value) => {
|
||||
setTestText(value);
|
||||
if (!value.trim()) {
|
||||
setTestOut(null);
|
||||
const onExport = async () => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const data = await apiJson('/pronunciation/export');
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
downloadBlob(blob, 'pronunciation-dictionary.json');
|
||||
} catch (e) {
|
||||
setError(e?.message || t('pronunciation.export_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const onImportFile = async (file) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
let imported;
|
||||
try {
|
||||
const parsed = JSON.parse(await file.text());
|
||||
imported = Array.isArray(parsed) ? parsed : parsed?.entries;
|
||||
if (!Array.isArray(imported)) throw new Error('not an entry list');
|
||||
} catch {
|
||||
setError(t('pronunciation.import_error'));
|
||||
return;
|
||||
}
|
||||
let replace = false;
|
||||
if (entries.length > 0) {
|
||||
replace = await askConfirm(
|
||||
t('pronunciation.import_replace_prompt', { count: entries.length }),
|
||||
t('pronunciation.backup_title'),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const r = await apiJson('/pronunciation/test', {
|
||||
const res = await apiJson('/pronunciation/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: value }),
|
||||
body: JSON.stringify({ entries: imported, replace }),
|
||||
});
|
||||
setTestOut(r);
|
||||
} catch {
|
||||
setTestOut(null);
|
||||
setNotice(t('pronunciation.import_done', { count: res?.imported ?? imported.length }));
|
||||
refresh();
|
||||
retest();
|
||||
} catch (e) {
|
||||
setError(e?.message || t('pronunciation.import_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const scopeLabel = (s) => (!s || s === '*' ? t('pronunciation.global') : s);
|
||||
const typeLabel = (ty) => t(`pronunciation.type_${ty}`, ty);
|
||||
|
||||
// Preview-language choices: Global plus every language the dictionary
|
||||
// actually scopes entries to (keep the current pick even if its last entry
|
||||
// was just deleted, so the select never renders an unknown value).
|
||||
const testLangs = [
|
||||
...new Set(
|
||||
entries
|
||||
.map((e) => e.scope || e.language)
|
||||
.filter((l) => l && l !== '*')
|
||||
.concat(testLang !== '*' ? [testLang] : []),
|
||||
),
|
||||
].sort();
|
||||
const hasScopedEnabled = entries.some((e) => e.enabled && (e.scope || e.language) !== '*');
|
||||
|
||||
return (
|
||||
<SettingsSection icon={BookA} title={t('pronunciation.title')}>
|
||||
<SettingRow title={t('pronunciation.title')} hint={t('pronunciation.help')} control={null} />
|
||||
@@ -147,7 +301,7 @@ export default function PronunciationPanel() {
|
||||
<SettingsToggle
|
||||
checked={!!e.enabled}
|
||||
onChange={() => onToggle(e)}
|
||||
aria-label={t('pronunciation.enabled')}
|
||||
aria-label={t('pronunciation.enable_entry', { term: e.term })}
|
||||
data-testid={`pron-toggle-${e.id}`}
|
||||
/>
|
||||
<Badge tone="neutral">{typeLabel(e.type)}</Badge>
|
||||
@@ -168,6 +322,7 @@ export default function PronunciationPanel() {
|
||||
|
||||
<SettingRow
|
||||
title={t('pronunciation.add')}
|
||||
hint={t('pronunciation.lang_label')}
|
||||
align="start"
|
||||
control={
|
||||
<div className="flex flex-wrap items-center gap-[6px] min-w-0 max-w-full">
|
||||
@@ -175,7 +330,9 @@ export default function PronunciationPanel() {
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(ev) => setTerm(ev.target.value)}
|
||||
onKeyDown={onAddKeyDown}
|
||||
placeholder={t('pronunciation.term_placeholder')}
|
||||
aria-label={t('pronunciation.term')}
|
||||
className="flex-[1_1_120px]"
|
||||
data-testid="pron-term"
|
||||
/>
|
||||
@@ -183,7 +340,9 @@ export default function PronunciationPanel() {
|
||||
type="text"
|
||||
value={replacement}
|
||||
onChange={(ev) => setReplacement(ev.target.value)}
|
||||
onKeyDown={onAddKeyDown}
|
||||
placeholder={t('pronunciation.replacement_placeholder')}
|
||||
aria-label={t('pronunciation.replacement')}
|
||||
className="flex-[1_1_120px]"
|
||||
data-testid="pron-replacement"
|
||||
/>
|
||||
@@ -191,6 +350,7 @@ export default function PronunciationPanel() {
|
||||
size="sm"
|
||||
value={type}
|
||||
onChange={(ev) => setType(ev.target.value)}
|
||||
aria-label={t('pronunciation.type')}
|
||||
data-testid="pron-type"
|
||||
>
|
||||
{TYPES.map((ty) => (
|
||||
@@ -203,11 +363,19 @@ export default function PronunciationPanel() {
|
||||
type="text"
|
||||
value={language}
|
||||
onChange={(ev) => setLanguage(ev.target.value)}
|
||||
placeholder={t('pronunciation.lang_label')}
|
||||
className="w-[90px] flex-none"
|
||||
onKeyDown={onAddKeyDown}
|
||||
placeholder={t('pronunciation.lang_placeholder')}
|
||||
aria-label={t('pronunciation.lang_label')}
|
||||
className="w-[130px] flex-none"
|
||||
data-testid="pron-language"
|
||||
/>
|
||||
<Button variant="subtle" size="sm" onClick={onAdd} data-testid="pron-add">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
disabled={!term.trim()}
|
||||
data-testid="pron-add"
|
||||
>
|
||||
{t('pronunciation.add')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -215,15 +383,35 @@ export default function PronunciationPanel() {
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('pronunciation.test_placeholder')}
|
||||
title={t('pronunciation.test_label')}
|
||||
subtitle={
|
||||
testLang === '*' && hasScopedEnabled ? t('pronunciation.test_global_hint') : undefined
|
||||
}
|
||||
control={
|
||||
<SettingsInput
|
||||
type="text"
|
||||
value={testText}
|
||||
onChange={(ev) => onTest(ev.target.value)}
|
||||
placeholder={t('pronunciation.test_placeholder')}
|
||||
data-testid="pron-test-input"
|
||||
/>
|
||||
<>
|
||||
<Select
|
||||
size="sm"
|
||||
value={testLang}
|
||||
onChange={(ev) => onTestLangChange(ev.target.value)}
|
||||
aria-label={t('pronunciation.test_language')}
|
||||
data-testid="pron-test-language"
|
||||
>
|
||||
<option value="*">{t('pronunciation.global')}</option>
|
||||
{testLangs.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<SettingsInput
|
||||
type="text"
|
||||
value={testText}
|
||||
onChange={(ev) => onTestTextChange(ev.target.value)}
|
||||
placeholder={t('pronunciation.test_placeholder')}
|
||||
aria-label={t('pronunciation.test_label')}
|
||||
data-testid="pron-test-input"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{testOut && (
|
||||
@@ -237,6 +425,49 @@ export default function PronunciationPanel() {
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{testError && (
|
||||
<p className="perfpanel__help" data-testid="pron-test-error">
|
||||
{t('pronunciation.test_error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title={t('pronunciation.backup_title')}
|
||||
hint={t('pronunciation.backup_hint')}
|
||||
control={
|
||||
<>
|
||||
<Button variant="subtle" size="sm" onClick={onExport} data-testid="pron-export">
|
||||
{t('pronunciation.export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
data-testid="pron-import"
|
||||
>
|
||||
{t('pronunciation.import')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
aria-label={t('pronunciation.import')}
|
||||
data-testid="pron-import-file"
|
||||
onChange={(ev) => {
|
||||
const f = ev.target.files?.[0];
|
||||
ev.target.value = '';
|
||||
if (f) onImportFile(f);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{notice && (
|
||||
<p className="perfpanel__help" data-testid="pron-import-done">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,50 +11,42 @@
|
||||
* GET /api/settings/dictation-refinement
|
||||
* → {auto, smart_cleanup, self_correction, preserve_technical, llm_ready}
|
||||
* PUT /api/settings/dictation-refinement body: partial of the above flags
|
||||
*
|
||||
* The section shell always renders: while loading it shows a muted loading
|
||||
* line, and when the initial GET fails it shows the error with a Retry button
|
||||
* instead of silently disappearing from Settings (the backend may just be
|
||||
* restarting). All strings go through i18n (`dictation.*`).
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Wand2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiJson, apiFetch } from '../../api/client';
|
||||
import { useAppStore } from '../../store';
|
||||
import { refineFailureNote } from './refineStatus';
|
||||
import { refineFailureNoteKey } from './refineStatus';
|
||||
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
|
||||
import { Button } from '../../ui';
|
||||
|
||||
const FLAG_ROWS = [
|
||||
[
|
||||
'auto',
|
||||
'Refine dictation with the local LLM',
|
||||
'Master switch — applied to final transcripts only, never live partials. The raw transcript is always kept in History.',
|
||||
],
|
||||
[
|
||||
'smart_cleanup',
|
||||
'Remove filler words & add punctuation',
|
||||
'"so um like the meeting is at 3pm you know" → "So the meeting is at 3pm."',
|
||||
],
|
||||
[
|
||||
'self_correction',
|
||||
'Apply spoken self-corrections',
|
||||
'"at seven no actually six am" → "at six am"',
|
||||
],
|
||||
[
|
||||
'preserve_technical',
|
||||
'Preserve technical terms & spoken symbols',
|
||||
'"index dot tsx" → "index.tsx"; identifiers stay verbatim',
|
||||
],
|
||||
];
|
||||
// Flag key → i18n label/hint pair (`dictation.flag_<key>` / `…_hint`).
|
||||
const FLAG_KEYS = ['auto', 'smart_cleanup', 'self_correction', 'preserve_technical'];
|
||||
|
||||
export default function RefinementPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [cfg, setCfg] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
setCfg(await apiJson('/api/settings/dictation-refinement'));
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to load refinement settings');
|
||||
setError(e?.message || t('dictation.load_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -71,62 +63,85 @@ export default function RefinementPanel() {
|
||||
});
|
||||
setCfg(await res.json());
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to save setting');
|
||||
setError(err?.message || t('dictation.save_error'));
|
||||
refresh();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!cfg) return null;
|
||||
const llmReady = Boolean(cfg.llm_ready);
|
||||
const failureNote = refineFailureNote(cfg.last_refine_status);
|
||||
const openLlmProviders = () => useAppStore.getState().openSettingsTab('llm-providers');
|
||||
|
||||
const llmReady = Boolean(cfg?.llm_ready);
|
||||
const failureNoteKey = refineFailureNoteKey(cfg?.last_refine_status);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Wand2}
|
||||
title="Dictation refinement"
|
||||
description={
|
||||
llmReady
|
||||
? undefined
|
||||
: 'Needs a local LLM endpoint — until then, raw transcripts paste unchanged.'
|
||||
title={t('dictation.title')}
|
||||
description={cfg && !llmReady ? t('dictation.needs_llm') : undefined}
|
||||
actions={
|
||||
// A first-time user with no LLM shouldn't dead-end on the description —
|
||||
// the configure step is one click away, before the first failure.
|
||||
cfg && !llmReady ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={openLlmProviders}
|
||||
data-testid="refine-open-llm"
|
||||
>
|
||||
{t('dictation.open_llm_providers')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<div className="perfpanel__error" role="alert">
|
||||
{error}
|
||||
{!cfg && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={refresh}
|
||||
data-testid="refine-retry"
|
||||
>
|
||||
{t('dictation.retry')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failureNote && (
|
||||
{!cfg && !error && loading && <p className="perfpanel__help">{t('common.loading')}</p>}
|
||||
|
||||
{cfg && failureNoteKey && (
|
||||
<div className="perfpanel__error" role="status">
|
||||
{failureNote}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => useAppStore.getState().openSettingsTab('llm-providers')}
|
||||
>
|
||||
Open LLM Providers
|
||||
{t(failureNoteKey)}{' '}
|
||||
<button type="button" className="underline" onClick={openLlmProviders}>
|
||||
{t('dictation.open_llm_providers')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{FLAG_ROWS.map(([key, label, help]) => (
|
||||
<SettingRow
|
||||
key={key}
|
||||
title={label}
|
||||
subtitle={key === 'auto' && !llmReady ? 'no LLM configured' : undefined}
|
||||
hint={help}
|
||||
control={
|
||||
<SettingsToggle
|
||||
checked={Boolean(cfg[key])}
|
||||
onChange={(next) => onToggle(key, next)}
|
||||
disabled={saving || (key !== 'auto' && !cfg.auto)}
|
||||
aria-label={label}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{cfg &&
|
||||
FLAG_KEYS.map((key) => (
|
||||
<SettingRow
|
||||
key={key}
|
||||
title={t(`dictation.flag_${key}`)}
|
||||
subtitle={key === 'auto' && !llmReady ? t('dictation.no_llm_configured') : undefined}
|
||||
hint={t(`dictation.flag_${key}_hint`)}
|
||||
control={
|
||||
<SettingsToggle
|
||||
checked={Boolean(cfg[key])}
|
||||
onChange={(next) => onToggle(key, next)}
|
||||
disabled={saving || (key !== 'auto' && !cfg.auto)}
|
||||
aria-label={t(`dictation.flag_${key}`)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,23 +7,41 @@
|
||||
* re-resolves the base. "Test" hits {url}/health (with the key) and shows
|
||||
* the remote's version + device.
|
||||
*
|
||||
* Saving is guarded: the URL must be a parseable http(s):// URL (a typo'd
|
||||
* base would brick every API call after the reload), and saving a URL that
|
||||
* hasn't passed a connection test asks for confirmation first.
|
||||
*
|
||||
* Pairs with the backend's OMNIVOICE_API_KEY bearer gate; full recipe in
|
||||
* docs/remote-gpu.md.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Server } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { LS_BACKEND_URL, LS_API_KEY, API } from '../../api/client';
|
||||
import { askConfirm } from '../../utils/dialog';
|
||||
import { SettingsSection, SettingRow, InfoHint, SettingsInput } from './primitives';
|
||||
import { Button, Badge } from '../../ui';
|
||||
import RestartBadge from './RestartBadge';
|
||||
|
||||
const REMOTE_GPU_DOCS_URL =
|
||||
'https://github.com/debpalash/OmniVoice-Studio/blob/main/docs/remote-gpu.md';
|
||||
|
||||
export default function RemoteBackendPanel() {
|
||||
/** A saved backend base must be a parseable absolute http(s) URL. */
|
||||
export function isValidBackendUrl(value) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RemoteBackendPanel({ reload = () => window.location.reload() }) {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState(() => localStorage.getItem(LS_BACKEND_URL) || '');
|
||||
const [key, setKey] = useState(() => localStorage.getItem(LS_API_KEY) || '');
|
||||
const [probe, setProbe] = useState(null); // {ok, detail}
|
||||
const [probe, setProbe] = useState(null); // {ok, detail, target}
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const normalized = url.trim().replace(/\/+$/, '');
|
||||
@@ -31,48 +49,87 @@ export default function RemoteBackendPanel() {
|
||||
const onTest = async () => {
|
||||
setTesting(true);
|
||||
setProbe(null);
|
||||
const target = normalized || API;
|
||||
try {
|
||||
const target = normalized || API;
|
||||
const res = await fetch(`${target}/health`, {
|
||||
headers: key.trim() ? { Authorization: `Bearer ${key.trim()}` } : {},
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body?.detail || `HTTP ${res.status}`);
|
||||
setProbe({ ok: true, detail: `${body.version || '?'} on ${body.device || '?'}` });
|
||||
setProbe({
|
||||
ok: true,
|
||||
detail: `${body.version || '?'} on ${body.device || '?'}`,
|
||||
target,
|
||||
});
|
||||
} catch (e) {
|
||||
setProbe({ ok: false, detail: e?.message || 'unreachable' });
|
||||
setProbe({
|
||||
ok: false,
|
||||
detail:
|
||||
e?.message || t('settings.remote_backend_unreachable', { defaultValue: 'unreachable' }),
|
||||
target,
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
if (normalized) localStorage.setItem(LS_BACKEND_URL, normalized);
|
||||
else localStorage.removeItem(LS_BACKEND_URL);
|
||||
const onSave = async () => {
|
||||
if (normalized) {
|
||||
if (!isValidBackendUrl(normalized)) {
|
||||
toast.error(
|
||||
t('settings.remote_backend_invalid_url', {
|
||||
defaultValue:
|
||||
'Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A wrong base bricks every API call after the reload — if this exact
|
||||
// URL hasn't passed a connection test, make the user confirm.
|
||||
const verified = probe?.ok && probe.target === normalized;
|
||||
if (!verified) {
|
||||
const go = await askConfirm(
|
||||
t('settings.remote_backend_confirm_unverified', {
|
||||
defaultValue:
|
||||
"This backend URL hasn't passed a connection test. Save it and reload anyway? " +
|
||||
"If it's wrong, the app can't reach any backend until you change it back here.",
|
||||
}),
|
||||
t('settings.remote_backend_confirm_title', { defaultValue: 'Use unverified backend?' }),
|
||||
);
|
||||
if (!go) return;
|
||||
}
|
||||
localStorage.setItem(LS_BACKEND_URL, normalized);
|
||||
} else {
|
||||
localStorage.removeItem(LS_BACKEND_URL);
|
||||
}
|
||||
if (key.trim()) localStorage.setItem(LS_API_KEY, key.trim());
|
||||
else localStorage.removeItem(LS_API_KEY);
|
||||
// api/client.ts resolves the base once at module load.
|
||||
window.location.reload();
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Server}
|
||||
title="Remote backend"
|
||||
description="Run inference on another machine; leave the URL empty for the local backend."
|
||||
title={t('settings.remote_backend_title', { defaultValue: 'Remote backend' })}
|
||||
description={t('settings.remote_backend_desc', {
|
||||
defaultValue:
|
||||
'Run inference on another machine; leave the URL empty for the local backend. ' +
|
||||
'Saving reloads the app to apply.',
|
||||
})}
|
||||
actions={
|
||||
<>
|
||||
<RestartBadge />
|
||||
<InfoHint learnMoreHref={REMOTE_GPU_DOCS_URL}>
|
||||
Start the backend on the other machine with <code>OMNIVOICE_API_KEY</code> set, reach it
|
||||
over your tailnet, and point this app at it.
|
||||
</InfoHint>
|
||||
</>
|
||||
<InfoHint learnMoreHref={REMOTE_GPU_DOCS_URL}>
|
||||
<Trans
|
||||
i18nKey="settings.remote_backend_hint"
|
||||
defaults="Start the backend on the other machine with <1>OMNIVOICE_API_KEY</1> set, reach it over your tailnet, and point this app at it."
|
||||
components={{ 1: <code /> }}
|
||||
/>
|
||||
</InfoHint>
|
||||
}
|
||||
>
|
||||
<SettingRow
|
||||
stack
|
||||
title="Backend URL"
|
||||
title={t('settings.remote_backend_url', { defaultValue: 'Backend URL' })}
|
||||
control={
|
||||
<SettingsInput
|
||||
mono
|
||||
@@ -80,19 +137,23 @@ export default function RemoteBackendPanel() {
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="http://gpu-box.tailnet.ts.net:3900"
|
||||
aria-label={t('settings.remote_backend_url', { defaultValue: 'Backend URL' })}
|
||||
data-testid="remote-backend-url"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
stack
|
||||
title="API key"
|
||||
title={t('settings.remote_backend_key', { defaultValue: 'API key' })}
|
||||
control={
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="value of OMNIVOICE_API_KEY on the server"
|
||||
placeholder={t('settings.remote_backend_key_placeholder', {
|
||||
defaultValue: 'value of OMNIVOICE_API_KEY on the server',
|
||||
})}
|
||||
aria-label={t('settings.remote_backend_key', { defaultValue: 'API key' })}
|
||||
data-testid="remote-backend-key"
|
||||
/>
|
||||
}
|
||||
@@ -107,14 +168,22 @@ export default function RemoteBackendPanel() {
|
||||
disabled={testing}
|
||||
data-testid="remote-backend-test"
|
||||
>
|
||||
Test connection
|
||||
{t('settings.remote_backend_test', { defaultValue: 'Test connection' })}
|
||||
</Button>
|
||||
<Button variant="subtle" size="sm" onClick={onSave} data-testid="remote-backend-save">
|
||||
Save & reload
|
||||
{t('settings.remote_backend_save', { defaultValue: 'Save & reload' })}
|
||||
</Button>
|
||||
{probe && (
|
||||
<Badge tone={probe.ok ? 'success' : 'danger'} dot role="status">
|
||||
{probe.ok ? `OK — ${probe.detail}` : `Failed — ${probe.detail}`}
|
||||
{probe.ok
|
||||
? t('settings.remote_backend_probe_ok', {
|
||||
detail: probe.detail,
|
||||
defaultValue: 'OK — {{detail}}',
|
||||
})
|
||||
: t('settings.remote_backend_probe_fail', {
|
||||
detail: probe.detail,
|
||||
defaultValue: 'Failed — {{detail}}',
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
LS_BACKEND_URL: 'ov_backend_url',
|
||||
LS_API_KEY: 'ov_api_key',
|
||||
API: 'http://127.0.0.1:3900',
|
||||
}));
|
||||
|
||||
// Shared confirmation dialog (Tauri-aware) — controlled per test.
|
||||
const { askConfirm } = vi.hoisted(() => ({ askConfirm: vi.fn() }));
|
||||
vi.mock('../../utils/dialog', () => ({ askConfirm }));
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
import RemoteBackendPanel, { isValidBackendUrl } from './RemoteBackendPanel';
|
||||
|
||||
describe('isValidBackendUrl', () => {
|
||||
it('accepts absolute http(s) URLs only', () => {
|
||||
expect(isValidBackendUrl('http://gpu-box:3900')).toBe(true);
|
||||
expect(isValidBackendUrl('https://gpu-box.tailnet.ts.net:3900')).toBe(true);
|
||||
// The classic typo: schemeless host:port parses as a URL with a bogus
|
||||
// protocol — it must NOT be accepted (it bricks every call post-reload).
|
||||
expect(isValidBackendUrl('gpu-box:3900')).toBe(false);
|
||||
expect(isValidBackendUrl('not a url')).toBe(false);
|
||||
expect(isValidBackendUrl('ftp://gpu-box')).toBe(false);
|
||||
expect(isValidBackendUrl('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteBackendPanel', () => {
|
||||
let reload;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
reload = vi.fn();
|
||||
});
|
||||
|
||||
const setUrl = (value) =>
|
||||
fireEvent.change(screen.getByTestId('remote-backend-url'), { target: { value } });
|
||||
const clickSave = () => fireEvent.click(screen.getByTestId('remote-backend-save'));
|
||||
|
||||
it('rejects an invalid URL instead of saving and reloading into a broken app', async () => {
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('gpu-box:3900');
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(askConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks for confirmation before saving an unverified URL, and aborts on decline', async () => {
|
||||
askConfirm.mockResolvedValue(false);
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://gpu-box:3900');
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => expect(askConfirm).toHaveBeenCalled());
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
});
|
||||
|
||||
it('saves and reloads an unverified URL when the user confirms', async () => {
|
||||
askConfirm.mockResolvedValue(true);
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://gpu-box:3900/');
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
// Trailing slashes are normalized before persisting.
|
||||
expect(localStorage.getItem('ov_backend_url')).toBe('http://gpu-box:3900');
|
||||
});
|
||||
|
||||
it('skips the confirmation when the exact URL passed a connection test', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: '0.3.15', device: 'cuda' }),
|
||||
});
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://gpu-box:3900');
|
||||
|
||||
fireEvent.click(screen.getByTestId('remote-backend-test'));
|
||||
await screen.findByText('OK — 0.3.15 on cuda');
|
||||
|
||||
clickSave();
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
expect(askConfirm).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('ov_backend_url')).toBe('http://gpu-box:3900');
|
||||
});
|
||||
|
||||
it('clears both settings and reloads without confirmation when the URL is emptied', async () => {
|
||||
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
|
||||
localStorage.setItem('ov_api_key', 'k');
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('');
|
||||
fireEvent.change(screen.getByTestId('remote-backend-key'), { target: { value: '' } });
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
expect(askConfirm).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders localized strings and labelled inputs (no hardcoded-English bypass)', () => {
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
// Strings resolve through i18n (en locale in tests) …
|
||||
expect(screen.getByText('Remote backend')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test connection')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save & reload')).toBeInTheDocument();
|
||||
// … and both inputs carry accessible names.
|
||||
expect(screen.getByLabelText('Backend URL')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('API key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -15,16 +15,48 @@ import { GROUPS } from './settingsCategories';
|
||||
* <optgroup> per group) so the whole IA stays reachable on a phone-width window.
|
||||
*
|
||||
* `visibleIds` (a Set) filters which categories render — the search box in the
|
||||
* parent drives it. Groups with no visible items are hidden entirely.
|
||||
* parent drives it. Groups with no visible items are hidden entirely; when the
|
||||
* search matches NOTHING, a "no results" empty state (with a clear-search
|
||||
* action) replaces both layouts so the nav never renders blank.
|
||||
*
|
||||
* @param {Set<string>} visibleIds category ids to show (search-filtered)
|
||||
* @param {string} active active category id
|
||||
* @param {function} onSelect (id) => void
|
||||
* @param {Set<string>} visibleIds category ids to show (search-filtered)
|
||||
* @param {string} active active category id
|
||||
* @param {function} onSelect (id) => void
|
||||
* @param {string=} query current search query (for the empty state)
|
||||
* @param {function=} onClearSearch clears the search query
|
||||
*/
|
||||
export default function SettingsSidebar({ visibleIds, active, onSelect }) {
|
||||
export default function SettingsSidebar({ visibleIds, active, onSelect, query, onClearSearch }) {
|
||||
const { t } = useTranslation();
|
||||
const isVisible = (id) => !visibleIds || visibleIds.has(id);
|
||||
const label = (it) => t(it.labelKey, { defaultValue: it.defaultLabel });
|
||||
const anyVisible = GROUPS.some((g) => g.items.some((it) => isVisible(it.id)));
|
||||
|
||||
if (!anyVisible) {
|
||||
return (
|
||||
<nav aria-label={t('settings.title', { defaultValue: 'Settings' })}>
|
||||
<div
|
||||
data-testid="settings-search-empty"
|
||||
className="px-[var(--space-3)] py-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[color:var(--chrome-fg-muted)]"
|
||||
>
|
||||
<p className="m-0 mb-[var(--space-3)]">
|
||||
{t('settings.search_no_results', {
|
||||
defaultValue: 'No settings match “{{query}}”',
|
||||
query: query ?? '',
|
||||
})}
|
||||
</p>
|
||||
{onClearSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSearch}
|
||||
className="cursor-pointer appearance-none rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] font-medium text-[color:var(--chrome-fg)] hover:text-[color:var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
|
||||
>
|
||||
{t('common.clear', { defaultValue: 'Clear' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={t('settings.title', { defaultValue: 'Settings' })}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user