Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
254f071b45 | ||
|
|
d1522cbba0 | ||
|
|
d3e88c0f3e | ||
|
|
fb0508f4f6 | ||
|
|
855a72038b | ||
|
|
e0a7b2202d | ||
|
|
f7be62207e | ||
|
|
7762bc48bd | ||
|
|
5562aa16a7 | ||
|
|
34c8a33628 | ||
|
|
17e2bb5ed5 | ||
|
|
fbac0de817 | ||
|
|
2e71cc3744 | ||
|
|
e816a2c24e | ||
|
|
fd083749c6 | ||
|
|
b603b9f78d | ||
|
|
2f3e40db24 | ||
|
|
6978f90f16 | ||
|
|
17ae952810 |
@@ -6,6 +6,28 @@ 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.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -280,59 +281,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()
|
||||
@@ -492,6 +453,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",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.17"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -1687,11 +1708,16 @@ 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
|
||||
"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
|
||||
@@ -1746,11 +1772,21 @@ 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),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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.17",
|
||||
"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.17"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.16"
|
||||
version = "0.3.17"
|
||||
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 —
|
||||
|
||||
@@ -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,6 +29,14 @@ 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.
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,15 @@ vi.mock('../../api/engines', () => ({
|
||||
selfTestEngine: 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 +51,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 +115,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' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -10,13 +10,17 @@
|
||||
* PUT /api/settings/hf-mirror body {url} (empty url clears → official)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { Globe, RefreshCw } 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(/\/+$/, '');
|
||||
|
||||
export default function HFMirrorPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState(null);
|
||||
@@ -52,6 +56,7 @@ export default function HFMirrorPanel() {
|
||||
const d = await res.json();
|
||||
setUrl(d.configured || '');
|
||||
setRestart(Boolean(d.restart_required));
|
||||
toast.success(t('models.mirror_saved', { defaultValue: 'Mirror setting saved' }));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e?.message || t('models.mirror_save_error'));
|
||||
@@ -60,8 +65,10 @@ export default function HFMirrorPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!state) return null;
|
||||
const configured = normalizeMirror(state?.configured);
|
||||
|
||||
// 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 +82,84 @@ 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"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => save(url)}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
data-testid="hf-mirror-save"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{!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">
|
||||
{state.presets.map((p) => (
|
||||
<Button
|
||||
variant="preset"
|
||||
key={p.label}
|
||||
active={normalizeMirror(p.url) === configured}
|
||||
onClick={() => save(p.url)}
|
||||
disabled={saving}
|
||||
data-testid={`hf-preset-${p.url || 'official'}`}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<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)}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
data-testid="hf-mirror-save"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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 STATE = {
|
||||
configured: 'https://hf-mirror.com',
|
||||
effective: 'https://hf-mirror.com',
|
||||
presets: [
|
||||
{ label: 'Official (huggingface.co)', url: '' },
|
||||
{ label: 'hf-mirror.com (community, China)', url: 'https://hf-mirror.com' },
|
||||
],
|
||||
};
|
||||
|
||||
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(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('marks the configured preset as active', async () => {
|
||||
apiJson.mockResolvedValue(STATE);
|
||||
render(<HFMirrorPanel />);
|
||||
|
||||
const mirror = await screen.findByTestId('hf-preset-https://hf-mirror.com');
|
||||
const official = screen.getByTestId('hf-preset-official');
|
||||
expect(mirror).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(official).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('labels the custom-URL row in plain language and toasts on save', async () => {
|
||||
apiJson.mockResolvedValue(STATE);
|
||||
apiFetch.mockResolvedValue({
|
||||
json: async () => ({ 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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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' })}>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import SettingsSidebar from './SettingsSidebar';
|
||||
|
||||
describe('SettingsSidebar — zero-match search empty state', () => {
|
||||
it('renders a "no results" message with the query and a Clear action instead of a blank nav', () => {
|
||||
const onClearSearch = vi.fn();
|
||||
render(
|
||||
<SettingsSidebar
|
||||
visibleIds={new Set()}
|
||||
active="general"
|
||||
onSelect={() => {}}
|
||||
query="zzz-no-such-setting"
|
||||
onClearSearch={onClearSearch}
|
||||
/>,
|
||||
);
|
||||
const empty = screen.getByTestId('settings-search-empty');
|
||||
expect(empty.textContent).toContain('zzz-no-such-setting');
|
||||
// Neither the (empty) narrow <select> nor any rail item renders.
|
||||
expect(screen.queryByTestId('settings-nav-select')).toBeNull();
|
||||
expect(screen.queryByTestId('settings-nav-general')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(onClearSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the full grouped nav (and the narrow select) when nothing is filtered', () => {
|
||||
render(<SettingsSidebar active="general" onSelect={() => {}} />);
|
||||
expect(screen.queryByTestId('settings-search-empty')).toBeNull();
|
||||
expect(screen.getByTestId('settings-nav-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('settings-nav-general')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('settings-nav-about')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only the matching categories when a filter set is provided', () => {
|
||||
render(
|
||||
<SettingsSidebar
|
||||
visibleIds={new Set(['network'])}
|
||||
active="network"
|
||||
onSelect={() => {}}
|
||||
query="proxy"
|
||||
onClearSearch={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('settings-nav-network')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('settings-nav-general')).toBeNull();
|
||||
expect(screen.queryByTestId('settings-search-empty')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,30 @@
|
||||
/**
|
||||
* Settings → Storage (System group).
|
||||
*
|
||||
* Shows where OmniVoice keeps its data and outputs (read-only, from systemInfo)
|
||||
* and provides a NEW "Factory reset" action that clears the locally-persisted
|
||||
* UI preferences (the zustand `omnivoice.app` localStorage blob) behind a
|
||||
* Shows where OmniVoice keeps its data and outputs (read-only, from systemInfo,
|
||||
* each with an Open-folder affordance via the /export/reveal endpoint) and a
|
||||
* "Factory reset" action that clears every locally-persisted UI preference —
|
||||
* the full registry in utils/prefKeys.js, not just the zustand blob — behind a
|
||||
* confirm Dialog, then reloads.
|
||||
*
|
||||
* NOTE: the models *cache* directory lives in the Models category (StoragePanel)
|
||||
* — this category is about the app's own data/outputs paths and a clean-slate
|
||||
* reset of UI prefs. Factory reset only touches localStorage prefs; it never
|
||||
* deletes the user's voices, projects, or outputs on disk.
|
||||
* deletes the user's voices, projects, or outputs on disk, and never wipes the
|
||||
* remote-backend connection or dictation history (see prefKeys.PRESERVED_KEYS).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { HardDrive, RotateCcw } from 'lucide-react';
|
||||
import { FolderOpen, HardDrive, RotateCcw } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSystemInfo } from '../../api/hooks';
|
||||
import { exportReveal } from '../../api/exports';
|
||||
import { clearLocalPreferences } from '../../utils/prefKeys';
|
||||
import { Button, Dialog } from '../../ui';
|
||||
import { SettingsSection } from './primitives';
|
||||
import Row from './Row';
|
||||
import HistoryRetentionPanel from './HistoryRetentionPanel';
|
||||
|
||||
// The zustand persist key (see store/index.ts `name`). Clearing it resets every
|
||||
// persisted UI preference to its slice default on the next load.
|
||||
const PREFS_LS_KEY = 'omnivoice.app';
|
||||
|
||||
export default function StorageTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: info } = useSystemInfo();
|
||||
@@ -32,7 +32,7 @@ export default function StorageTab() {
|
||||
|
||||
const factoryReset = () => {
|
||||
try {
|
||||
localStorage.removeItem(PREFS_LS_KEY);
|
||||
clearLocalPreferences();
|
||||
toast.success(
|
||||
t('settings.factory_reset_done', { defaultValue: 'Preferences cleared — reloading…' }),
|
||||
);
|
||||
@@ -41,11 +41,48 @@ export default function StorageTab() {
|
||||
setTimeout(() => window.location.reload(), 350);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t('settings.factory_reset_failed', { defaultValue: 'Reset failed', message: e?.message }),
|
||||
t('settings.factory_reset_failed', {
|
||||
defaultValue: 'Reset failed: {{message}}',
|
||||
message: e?.message || e,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openFolder = async (path) => {
|
||||
try {
|
||||
await exportReveal({ path });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e?.message || t('settings.open_folder_failed', { defaultValue: 'Could not open folder' }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const pathRow = (label, path, testId) => (
|
||||
<Row
|
||||
label={label}
|
||||
value={
|
||||
<>
|
||||
<span>{path || '—'}</span>
|
||||
{path && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leading={<FolderOpen size={12} />}
|
||||
onClick={() => openFolder(path)}
|
||||
title={path}
|
||||
data-testid={testId}
|
||||
>
|
||||
{t('settings.storage_open_folder', { defaultValue: 'Open folder' })}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
mono
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
@@ -55,13 +92,13 @@ export default function StorageTab() {
|
||||
defaultValue: 'Where OmniVoice keeps your data and outputs.',
|
||||
})}
|
||||
>
|
||||
<Row
|
||||
label={t('privacy.uploads_at')}
|
||||
value={info?.data_dir ? `${info.data_dir}/` : '—'}
|
||||
mono
|
||||
/>
|
||||
<Row label={t('privacy.outputs_at')} value={info?.outputs_dir || '—'} mono />
|
||||
<Row label={t('about.crash_log')} value={info?.crash_log_path || '—'} mono />
|
||||
{pathRow(
|
||||
t('settings.data_dir_at', { defaultValue: 'App data stored at' }),
|
||||
info?.data_dir ? `${info.data_dir}/` : '',
|
||||
'storage-open-data-dir',
|
||||
)}
|
||||
{pathRow(t('privacy.outputs_at'), info?.outputs_dir || '', 'storage-open-outputs-dir')}
|
||||
{pathRow(t('about.crash_log'), info?.crash_log_path || '', 'storage-open-crash-log')}
|
||||
</SettingsSection>
|
||||
|
||||
<HistoryRetentionPanel />
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import StorageTab from './StorageTab';
|
||||
|
||||
const INFO = {
|
||||
data_dir: '/home/u/.omnivoice',
|
||||
outputs_dir: '/home/u/.omnivoice/outputs',
|
||||
crash_log_path: '/home/u/.omnivoice/crash_log.txt',
|
||||
};
|
||||
|
||||
/** URL-aware fetch mock: /system/info + history-retention + export/reveal. */
|
||||
function mockFetch() {
|
||||
const fn = vi.fn(async (url) => {
|
||||
const body = /\/system\/info/.test(url)
|
||||
? INFO
|
||||
: /history-retention/.test(url)
|
||||
? { cap: 200, default: 200 }
|
||||
: { success: true };
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
});
|
||||
global.fetch = fn;
|
||||
return fn;
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<StorageTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('StorageTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('labels the data dir as app data (not uploads) and offers Open folder on every path', async () => {
|
||||
mockFetch();
|
||||
renderTab();
|
||||
await waitFor(() => expect(screen.getByText(`${INFO.data_dir}/`)).toBeInTheDocument());
|
||||
expect(screen.getByText('App data stored at')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('storage-open-data-dir')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('storage-open-outputs-dir')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('storage-open-crash-log')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Open folder reveals the path via /export/reveal', async () => {
|
||||
const fetchMock = mockFetch();
|
||||
renderTab();
|
||||
const btn = await screen.findByTestId('storage-open-outputs-dir');
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
const call = fetchMock.mock.calls.find(([u]) => u.endsWith('/export/reveal'));
|
||||
expect(call).toBeTruthy();
|
||||
expect(JSON.parse(call[1].body)).toEqual({ path: INFO.outputs_dir });
|
||||
});
|
||||
});
|
||||
|
||||
it('factory reset clears every registered preference key, not just the zustand blob', async () => {
|
||||
mockFetch();
|
||||
// Preferences scattered across the app (the pre-registry bug left these behind):
|
||||
localStorage.setItem('omnivoice.app', '{"state":{}}');
|
||||
localStorage.setItem('omnivoice.navRailSide', 'right');
|
||||
localStorage.setItem('omnivoice.settings.category', 'storage');
|
||||
localStorage.setItem('omni_capture_live_typing', '1');
|
||||
localStorage.setItem('ov_stories_global_speed', '1.4');
|
||||
localStorage.setItem('omni_ui', '{"uiScale":1.2}');
|
||||
localStorage.setItem('dismissed_lang_suggestion', 'true');
|
||||
// User data + connection state that must survive a reset:
|
||||
localStorage.setItem('omni_transcriptions', '[{"text":"note"}]');
|
||||
localStorage.setItem('ov_backend_url', 'http://192.168.1.4:7842');
|
||||
|
||||
renderTab();
|
||||
await waitFor(() => expect(screen.getByTestId('factory-reset-open')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('factory-reset-open'));
|
||||
await waitFor(() => expect(screen.getByTestId('factory-reset-confirm')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('factory-reset-confirm'));
|
||||
|
||||
await waitFor(() => expect(localStorage.getItem('omnivoice.app')).toBeNull());
|
||||
expect(localStorage.getItem('omnivoice.navRailSide')).toBeNull();
|
||||
expect(localStorage.getItem('omnivoice.settings.category')).toBeNull();
|
||||
expect(localStorage.getItem('omni_capture_live_typing')).toBeNull();
|
||||
expect(localStorage.getItem('ov_stories_global_speed')).toBeNull();
|
||||
expect(localStorage.getItem('omni_ui')).toBeNull();
|
||||
expect(localStorage.getItem('dismissed_lang_suggestion')).toBeNull();
|
||||
// Never touch user data or the remote-backend connection:
|
||||
expect(localStorage.getItem('omni_transcriptions')).toBe('[{"text":"note"}]');
|
||||
expect(localStorage.getItem('ov_backend_url')).toBe('http://192.168.1.4:7842');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { exportReveal } from '../../api/exports';
|
||||
import { clearSystemLogs } from '../../api/system';
|
||||
import { useAppStore } from '../../store';
|
||||
import { fmtBytes } from './models/format';
|
||||
import { askConfirm } from './native';
|
||||
import { SettingsSection } from './primitives';
|
||||
|
||||
// Once-per-session guard for the out-of-Settings critical toast.
|
||||
@@ -137,7 +138,10 @@ export default function StorageUsagePanel() {
|
||||
toast.error(warningText(t, critical), { id: 'storage-critical', duration: 8000 });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Failed to load storage info');
|
||||
setError(
|
||||
e?.message ||
|
||||
t('settings.storage_load_failed', { defaultValue: 'Failed to load storage info' }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
@@ -154,17 +158,58 @@ export default function StorageUsagePanel() {
|
||||
try {
|
||||
await exportReveal({ path });
|
||||
} catch (e) {
|
||||
toast.error(e?.message || 'Could not open folder');
|
||||
toast.error(
|
||||
e?.message || t('settings.open_folder_failed', { defaultValue: 'Could not open folder' }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Same confirm gate as Settings → Logs → Clear: this truncates the crash log
|
||||
// too (the primary bug-report artifact), so it must never be one stray click.
|
||||
const clearLogs = async () => {
|
||||
const ok = await askConfirm(
|
||||
t('settings.clear_backend_confirm', {
|
||||
defaultValue: 'Clear the backend runtime + crash logs? This cannot be undone.',
|
||||
}),
|
||||
t('settings.clear_backend_title', { defaultValue: 'Clear logs' }),
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await clearSystemLogs();
|
||||
toast.success(t('settings.storage_logs_cleared', { defaultValue: 'Logs cleared' }));
|
||||
load(true);
|
||||
} catch (e) {
|
||||
toast.error(e?.message || 'Could not clear logs');
|
||||
toast.error(
|
||||
e?.message || t('settings.clear_backend_failed', { defaultValue: 'Failed to clear logs' }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const clearTemp = async () => {
|
||||
const ok = await askConfirm(
|
||||
t('settings.storage_clear_temp_confirm', {
|
||||
defaultValue:
|
||||
"Delete OmniVoice's temporary working files? Don't do this while a dub or batch job is running.",
|
||||
}),
|
||||
t('settings.storage_clear_temp', { defaultValue: 'Clear temp files' }),
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await apiJson('/api/settings/storage/temp/clear', { method: 'POST' });
|
||||
toast.success(
|
||||
t('settings.storage_temp_cleared', {
|
||||
defaultValue: 'Temporary files cleared — {{freed}} freed',
|
||||
freed: fmtBytes(r?.freed_bytes || 0),
|
||||
}),
|
||||
);
|
||||
load(true);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e?.message ||
|
||||
t('settings.storage_clear_temp_failed', {
|
||||
defaultValue: 'Could not clear temporary files',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -313,6 +358,19 @@ export default function StorageUsagePanel() {
|
||||
{t('settings.storage_manage_models', { defaultValue: 'Manage models' })}
|
||||
</SmallButton>
|
||||
)}
|
||||
{cat.id === 'temp' && (
|
||||
<SmallButton
|
||||
onClick={clearTemp}
|
||||
title={t('settings.storage_clear_temp_hint', {
|
||||
defaultValue: "Delete OmniVoice's own files in the temp directory",
|
||||
})}
|
||||
testId="storage-clear-temp"
|
||||
disabled={(cat.bytes || 0) === 0}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{t('settings.storage_clear_temp', { defaultValue: 'Clear temp files' })}
|
||||
</SmallButton>
|
||||
)}
|
||||
{cat.exists && (
|
||||
<SmallButton
|
||||
onClick={() => openFolder(cat.path)}
|
||||
|
||||
@@ -164,6 +164,65 @@ describe('StorageUsagePanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Clear logs is confirm-gated: declining leaves the logs untouched', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const fetchMock = mockFetchWith(REPORT);
|
||||
render(<StorageUsagePanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('storage-clear-logs')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('storage-clear-logs'));
|
||||
|
||||
await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/cannot be undone/i);
|
||||
expect(fetchMock.mock.calls.filter(([u]) => /\/system\/logs\/clear/.test(u))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Clear logs POSTs after the user confirms', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const fetchMock = mockFetchWith(REPORT);
|
||||
render(<StorageUsagePanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('storage-clear-logs')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('storage-clear-logs'));
|
||||
|
||||
await waitFor(() => {
|
||||
const call = fetchMock.mock.calls.find(([u]) => /\/system\/logs\/clear/.test(u));
|
||||
expect(call).toBeTruthy();
|
||||
expect(call[1]?.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
it('Clear temp files is confirm-gated and hits the temp-clear endpoint', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const report = {
|
||||
...REPORT,
|
||||
categories: REPORT.categories.map((c) =>
|
||||
c.id === 'temp' ? { ...c, bytes: 512 * 1024 ** 2 } : c,
|
||||
),
|
||||
};
|
||||
const fetchMock = mockFetchWith(report);
|
||||
render(<StorageUsagePanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('storage-clear-temp')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('storage-clear-temp')).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('storage-clear-temp'));
|
||||
|
||||
await waitFor(() => {
|
||||
const call = fetchMock.mock.calls.find(([u]) =>
|
||||
u.endsWith('/api/settings/storage/temp/clear'),
|
||||
);
|
||||
expect(call).toBeTruthy();
|
||||
expect(call[1]?.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
it('Clear temp files is disabled when there is nothing to reclaim', async () => {
|
||||
mockFetchWith(REPORT); // REPORT's temp category is 0 bytes
|
||||
render(<StorageUsagePanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('storage-clear-temp')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('storage-clear-temp')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the load error state when the endpoint fails', async () => {
|
||||
// An HTTP error (not a transport failure) — apiFetch surfaces it without retrying.
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import { Table } from '../../../ui';
|
||||
import { Button, Table } from '../../../ui';
|
||||
|
||||
/**
|
||||
* Virtualized model table view. Purely presentational — the table instance,
|
||||
@@ -14,6 +14,9 @@ export default function ModelsTable({
|
||||
tableBodyRef,
|
||||
getRowRuntime,
|
||||
t,
|
||||
// Optional: when provided, the "no matches" empty state offers a one-click
|
||||
// way back to the full list instead of a dead end.
|
||||
onClearFilters,
|
||||
}) {
|
||||
return (
|
||||
<Table className="models-table">
|
||||
@@ -93,7 +96,20 @@ export default function ModelsTable({
|
||||
);
|
||||
})}
|
||||
{tableRows.length === 0 && (
|
||||
<div className="models-table__empty">{t('models.no_matches')}</div>
|
||||
<div className="models-table__empty">
|
||||
<span>{t('models.no_matches')}</span>
|
||||
{onClearFilters && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
className="ml-[8px]"
|
||||
onClick={onClearFilters}
|
||||
data-testid="models-clear-filters"
|
||||
>
|
||||
{t('models.clear_filters')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,9 @@ export default function RecoBanner({
|
||||
installingReco,
|
||||
setInstallingReco,
|
||||
onInstallRecommended,
|
||||
// Free space (GB) on the model-cache volume, from GET /models — gives the
|
||||
// download buttons context and warns BEFORE a doomed multi-GB download.
|
||||
diskFreeGb = null,
|
||||
}) {
|
||||
if (!reco) return null;
|
||||
if (reco.all_installed) {
|
||||
@@ -79,6 +82,28 @@ export default function RecoBanner({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Disk context next to the download actions: how much room the
|
||||
download has, and a plain warning when it won't fit. */}
|
||||
{diskFreeGb != null && (
|
||||
<div
|
||||
className="-mt-[2px] text-[length:var(--text-2xs)] text-[var(--chrome-fg-dim)]"
|
||||
data-testid="reco-disk-context"
|
||||
>
|
||||
{t('models.reco_disk_free', { free: diskFreeGb })}
|
||||
</div>
|
||||
)}
|
||||
{diskFreeGb != null && Number(reco.download_gb_remaining) > Number(diskFreeGb) && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-[length:var(--text-xs)] text-[var(--chrome-severity-warn)]"
|
||||
data-testid="reco-low-disk"
|
||||
>
|
||||
{t('models.reco_low_disk', {
|
||||
need: reco.download_gb_remaining,
|
||||
free: diskFreeGb,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-0 text-[length:var(--text-sm)] leading-[1.6]">
|
||||
{reco.models.map((m) => (
|
||||
<span
|
||||
|
||||
@@ -21,6 +21,11 @@ export function makeModelColumns({
|
||||
onReinstall,
|
||||
onCancel,
|
||||
onDismissError,
|
||||
// Residency (optional): repo_id → its /model/loaded entry when that model
|
||||
// is resident in memory right now, else null/undefined. Legacy callers that
|
||||
// don't pass it render exactly as before.
|
||||
getResidency,
|
||||
onUnload,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
@@ -34,7 +39,9 @@ export function makeModelColumns({
|
||||
const rt = getRowRuntime(m);
|
||||
return (
|
||||
<>
|
||||
<span className="models-row__title">
|
||||
{/* Both lines truncate with ellipsis (CSS) — the full text stays
|
||||
reachable via the title attributes. */}
|
||||
<span className="models-row__title" title={m.label}>
|
||||
<span
|
||||
className="models-row__avatar"
|
||||
style={{ background: orgColor(m.repo_id) }}
|
||||
@@ -45,7 +52,10 @@ export function makeModelColumns({
|
||||
{m.label}
|
||||
{m.required && <span className="models-row__tag">{t('models.required_tag')}</span>}
|
||||
</span>
|
||||
<span className="models-row__repo">
|
||||
<span
|
||||
className="models-row__repo"
|
||||
title={m.note ? `${m.repo_id} · ${m.note}` : m.repo_id}
|
||||
>
|
||||
<code>{m.repo_id}</code>
|
||||
{m.note && <span className="models-row__note"> · {m.note}</span>}
|
||||
</span>
|
||||
@@ -236,9 +246,27 @@ export function makeModelColumns({
|
||||
<RefreshCw size={10} className="spinner" /> {t('models.working')}
|
||||
</Badge>
|
||||
) : m.installed ? (
|
||||
<Badge tone="success" size="xs">
|
||||
{t('models.installed')}
|
||||
</Badge>
|
||||
getResidency?.(m) ? (
|
||||
// Installed AND resident in memory right now (/model/loaded
|
||||
// checkpoint match). Named so users know unloading is safe.
|
||||
<span className="inline-flex flex-col items-center gap-[3px]">
|
||||
<Badge tone="success" size="xs">
|
||||
{t('models.installed')}
|
||||
</Badge>
|
||||
<Badge
|
||||
tone="info"
|
||||
size="xs"
|
||||
title={t('models.in_memory_title')}
|
||||
data-testid={`model-resident-${m.repo_id}`}
|
||||
>
|
||||
{t('models.in_memory')}
|
||||
</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<Badge tone="success" size="xs">
|
||||
{t('models.installed')}
|
||||
</Badge>
|
||||
)
|
||||
) : m.incomplete ? (
|
||||
// A truncated download (backend `incomplete`): config/tokenizer landed
|
||||
// but the weight shard didn't, so it still occupies disk yet can't be
|
||||
@@ -324,6 +352,23 @@ export function makeModelColumns({
|
||||
{t('models.cancel_btn')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Free the memory this model occupies right now. Only when the
|
||||
/model/loaded entry says it's unloadable — it reloads lazily
|
||||
on next use, so this never loses data. */}
|
||||
{(() => {
|
||||
const resident = getResidency?.(m);
|
||||
return resident?.unloadable && !rt.rowBusy && !rt.isDeleting && onUnload ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => onUnload(m.repo_id)}
|
||||
title={t('models.in_memory_title')}
|
||||
aria-label={t('models.unload_aria', { repoId: m.repo_id })}
|
||||
>
|
||||
{t('models.unload_btn')}
|
||||
</Button>
|
||||
) : null;
|
||||
})()}
|
||||
{m.installed && !rt.rowBusy && !rt.isDeleting && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
* The dictation final is never blocked (the hard refine timeout inserts the raw
|
||||
* text regardless), so this message is purely informational.
|
||||
*
|
||||
* Returns the user-facing string, or null when the last refinement succeeded /
|
||||
* hasn't run.
|
||||
* Returns the i18n key of the user-facing message (resolve with `t(key)`), or
|
||||
* null when the last refinement succeeded / hasn't run. Returning a key —
|
||||
* rather than a hardcoded English sentence — keeps the note translatable like
|
||||
* every other UI string.
|
||||
*/
|
||||
export function refineFailureNote(status) {
|
||||
export function refineFailureNoteKey(status) {
|
||||
if (!status || status.ok !== false) return null;
|
||||
return status.reason === 'timeout'
|
||||
? 'The last dictation refinement timed out — the LLM endpoint is slow or unreachable. Dictation still works (the raw transcript is inserted). Test the connection in LLM Providers.'
|
||||
: 'The last dictation refinement failed — the configured LLM endpoint rejected the request. Dictation still works (the raw transcript is inserted). Test the connection in LLM Providers.';
|
||||
? 'dictation.refine_timeout_note'
|
||||
: 'dictation.refine_failed_note';
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
*
|
||||
* Keep this declarative — no JSX panels here (those need props/hooks and live
|
||||
* in Settings.jsx's renderCategory switch). `keywords` powers the bonus
|
||||
* "search matches a setting → jump to its category" behaviour.
|
||||
* "search matches a setting → jump to its category" behaviour; `keywordKeys`
|
||||
* lists i18n keys of prominent setting-row titles so the same search works in
|
||||
* every UI language (the translated titles are matched at query time — no
|
||||
* separate keyword translations to maintain).
|
||||
*/
|
||||
import {
|
||||
AudioLines,
|
||||
Palette,
|
||||
Settings2,
|
||||
Plug,
|
||||
@@ -54,6 +58,13 @@ export const GROUPS = [
|
||||
'header live stats',
|
||||
'system metrics',
|
||||
],
|
||||
keywordKeys: [
|
||||
'settings.ui_scale',
|
||||
'settings.color_theme',
|
||||
'settings.font',
|
||||
'settings.autoplay_preview',
|
||||
'settings.header_live_stats',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
@@ -61,6 +72,7 @@ export const GROUPS = [
|
||||
defaultLabel: 'General',
|
||||
icon: Settings2,
|
||||
keywords: ['language', 'locale', 'interface language', 'review mode', 'stage checkpoints'],
|
||||
keywordKeys: ['settings.language', 'settings.review_mode'],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -106,6 +118,7 @@ export const GROUPS = [
|
||||
'microphone',
|
||||
'voice capture',
|
||||
],
|
||||
keywordKeys: ['settings.shortcut'],
|
||||
},
|
||||
{
|
||||
id: 'pronunciation',
|
||||
@@ -129,6 +142,7 @@ export const GROUPS = [
|
||||
'openai',
|
||||
'api key',
|
||||
],
|
||||
keywordKeys: ['settings.translate_quality', 'settings.translation_providers'],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -173,13 +187,38 @@ export const GROUPS = [
|
||||
'temp files',
|
||||
'clear logs',
|
||||
],
|
||||
keywordKeys: ['settings.storage_usage', 'settings.factory_reset'],
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
labelKey: 'settings.network',
|
||||
defaultLabel: 'Network',
|
||||
icon: Wifi,
|
||||
keywords: ['network', 'proxy', 'http proxy', 'socks', 'ffmpeg', 'ffmpeg path'],
|
||||
// Only the proxy lives here now (applies immediately) — the
|
||||
// restart-bound FFmpeg override moved to Audio tools below.
|
||||
keywords: ['network', 'proxy', 'http proxy', 'socks'],
|
||||
keywordKeys: ['settings.proxy'],
|
||||
},
|
||||
{
|
||||
id: 'audio-tools',
|
||||
labelKey: 'settings.audio_tools',
|
||||
defaultLabel: 'Audio tools',
|
||||
icon: AudioLines,
|
||||
// yt-dlp updates land in an overlay read at process start (the row
|
||||
// renders RestartBadge) — lockstep-guarded in
|
||||
// settingsCategories.test.jsx like Models / Performance / Sharing.
|
||||
restart: true,
|
||||
keywords: [
|
||||
'ffmpeg',
|
||||
'ffprobe',
|
||||
'ffmpeg path',
|
||||
'yt-dlp',
|
||||
'ytdlp',
|
||||
'media engine',
|
||||
'video downloader',
|
||||
'bundled binaries',
|
||||
],
|
||||
keywordKeys: ['settings.audio_tools', 'settings.ffmpeg', 'settings.audio_tools_ytdlp'],
|
||||
},
|
||||
{
|
||||
id: 'sharing',
|
||||
@@ -202,6 +241,7 @@ export const GROUPS = [
|
||||
defaultLabel: 'Credentials',
|
||||
icon: KeyRound,
|
||||
keywords: ['credentials', 'hugging face token', 'hf token', 'api key', 'secret'],
|
||||
keywordKeys: ['settings.hf_token_title'],
|
||||
},
|
||||
{
|
||||
id: 'llm-providers',
|
||||
@@ -221,6 +261,7 @@ export const GROUPS = [
|
||||
'autofit',
|
||||
'translation quality',
|
||||
],
|
||||
keywordKeys: ['settings.llmp_provider', 'settings.llmp_api_key', 'settings.llmp_model'],
|
||||
},
|
||||
{
|
||||
id: 'llm-skills',
|
||||
@@ -276,6 +317,7 @@ export const GROUPS = [
|
||||
defaultLabel: 'About',
|
||||
icon: Info,
|
||||
keywords: ['about', 'version', 'license', 'diagnostics', 'self check'],
|
||||
keywordKeys: ['about.version', 'about.self_check'],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -310,13 +352,22 @@ export function resolveCategoryId(id) {
|
||||
* Given a lowercased query, return the set of category ids whose label OR any
|
||||
* keyword matches. Used to filter the sidebar and to power "search a setting →
|
||||
* jump to its category".
|
||||
*
|
||||
* @param {string} query
|
||||
* @param {function=} labelFor (category) => translated label
|
||||
* @param {function=} translate i18n `t` — lets `keywordKeys` (setting-row
|
||||
* title keys) match in the active UI language, so a German user finds
|
||||
* Appearance by "Schriftart" just like an English user finds it by "font".
|
||||
* The English `keywords` always match too, in every locale.
|
||||
*/
|
||||
export function matchCategories(query, labelFor) {
|
||||
export function matchCategories(query, labelFor, translate) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return CATEGORIES.map((c) => c.id);
|
||||
return CATEGORIES.filter((c) => {
|
||||
const label = (labelFor ? labelFor(c) : c.defaultLabel).toLowerCase();
|
||||
if (label.includes(q)) return true;
|
||||
return (c.keywords || []).some((k) => k.toLowerCase().includes(q));
|
||||
if ((c.keywords || []).some((k) => k.toLowerCase().includes(q))) return true;
|
||||
if (!translate) return false;
|
||||
return (c.keywordKeys || []).some((key) => String(translate(key)).toLowerCase().includes(q));
|
||||
}).map((c) => c.id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { CATEGORIES, CATEGORY_BY_ID, matchCategories } from './settingsCategories';
|
||||
import en from '../../i18n/locales/en.json';
|
||||
|
||||
const SETTINGS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('matchCategories — search matching', () => {
|
||||
it('an empty query returns every category', () => {
|
||||
expect(matchCategories('')).toEqual(CATEGORIES.map((c) => c.id));
|
||||
});
|
||||
|
||||
it('a query matching nothing returns an empty list (drives the sidebar empty state)', () => {
|
||||
expect(matchCategories('zzz-no-such-setting')).toEqual([]);
|
||||
});
|
||||
|
||||
it('English keywords match in every locale (no translate fn needed)', () => {
|
||||
expect(matchCategories('proxy')).toContain('network');
|
||||
expect(matchCategories('ui scale')).toContain('appearance');
|
||||
});
|
||||
|
||||
it('keywordKeys match through the active locale, so localized setting names find their category', () => {
|
||||
// Simulate a German UI: settings.font resolves to "Schriftart".
|
||||
const t = (key) => (key === 'settings.font' ? 'Schriftart' : key);
|
||||
expect(matchCategories('schriftart', undefined, t)).toContain('appearance');
|
||||
// English keywords keep working alongside the translated titles.
|
||||
expect(matchCategories('font', undefined, t)).toContain('appearance');
|
||||
});
|
||||
|
||||
it('every keywordKey points at a real en.json string (typo guard)', () => {
|
||||
for (const c of CATEGORIES) {
|
||||
for (const key of c.keywordKeys || []) {
|
||||
const value = key.split('.').reduce((node, part) => node?.[part], en);
|
||||
expect(typeof value, `${c.id}: ${key} missing from en.json`).toBe('string');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('restart flag ↔ RestartBadge lockstep', () => {
|
||||
// Panel file → hosting category (per Settings.jsx renderCategory). Any panel
|
||||
// that renders the "Restart required" badge must live in a category flagged
|
||||
// restart: true, or the sidebar ↻ glyph / header badge contract breaks
|
||||
// (that drift is exactly how Network shipped without its glyph).
|
||||
const PANEL_CATEGORY = {
|
||||
'StoragePanel.jsx': 'models',
|
||||
'HFMirrorPanel.jsx': 'models',
|
||||
'RemoteBackendPanel.jsx': 'sharing',
|
||||
'AudioToolsPanel.jsx': 'audio-tools',
|
||||
'PerformancePanel.jsx': 'performance',
|
||||
};
|
||||
|
||||
const panelsUsingRestartBadge = fs
|
||||
.readdirSync(SETTINGS_DIR)
|
||||
.filter((f) => f.endsWith('.jsx') && !f.includes('.test.') && f !== 'RestartBadge.jsx')
|
||||
.filter((f) => {
|
||||
const src = fs.readFileSync(path.join(SETTINGS_DIR, f), 'utf8');
|
||||
// Only the restart-warning form counts; `<RestartBadge applies` is the
|
||||
// "Applies now" affordance and needs no category flag.
|
||||
return /<RestartBadge(?!\s+applies)/.test(src);
|
||||
});
|
||||
|
||||
it('finds the known restart-badge panels (scan sanity check)', () => {
|
||||
expect(panelsUsingRestartBadge.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(panelsUsingRestartBadge)('%s belongs to a restart-flagged category', (file) => {
|
||||
const categoryId = PANEL_CATEGORY[file];
|
||||
expect(
|
||||
categoryId,
|
||||
`${file} renders <RestartBadge /> but has no category mapping — add it to PANEL_CATEGORY and flag the category`,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
CATEGORY_BY_ID[categoryId]?.restart,
|
||||
`category "${categoryId}" hosts ${file} (restart-bound setting) but lacks restart: true in settingsCategories.jsx`,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,24 @@ import { mergeDescribedAttrs } from '../utils/voiceInstruct';
|
||||
* Encapsulates all data-loading effects, localStorage persistence,
|
||||
* real-time WebSocket updates, and model-status pill management.
|
||||
*/
|
||||
|
||||
// Dub steps that describe live, in-process work ('uploading', 'transcribing',
|
||||
// 'generating', 'stopping') must never be restored across an app restart: the
|
||||
// task they referred to died with the process, so rehydrating one leaves the
|
||||
// Dub tab waiting forever on progress that will never arrive (blank pane +
|
||||
// eternal spinner — the "stuck on dubbing since I updated" reports, and a
|
||||
// reinstall doesn't clear the webview's localStorage). Only settled states
|
||||
// come back.
|
||||
const STABLE_DUB_STEPS = new Set(['idle', 'editing', 'done']);
|
||||
|
||||
/** Clamp a persisted dubStep to a state that is valid after a cold start.
|
||||
* Stable steps pass through; transient (and unknown/corrupt) values fall
|
||||
* back to 'editing' when the session has segments to show, else 'idle'. */
|
||||
export function clampRestoredDubStep(savedStep, savedSegments) {
|
||||
if (STABLE_DUB_STEPS.has(savedStep)) return savedStep;
|
||||
return Array.isArray(savedSegments) && savedSegments.length > 0 ? 'editing' : 'idle';
|
||||
}
|
||||
|
||||
export default function useAppData() {
|
||||
const mode = useAppStore((s) => s.mode);
|
||||
const setMode = useAppStore((s) => s.setMode);
|
||||
@@ -194,7 +212,7 @@ export default function useAppData() {
|
||||
if (saved.dubLang) setDubLang(saved.dubLang);
|
||||
if (saved.dubLangCode) setDubLangCode(saved.dubLangCode);
|
||||
if (saved.dubTracks) setDubTracks(saved.dubTracks);
|
||||
if (saved.dubStep) setDubStep(saved.dubStep);
|
||||
if (saved.dubStep) setDubStep(clampRestoredDubStep(saved.dubStep, saved.dubSegments));
|
||||
if (saved.dubTranscript) setDubTranscript(saved.dubTranscript);
|
||||
if (saved.exportTracks) setExportTracks(saved.exportTracks);
|
||||
if (saved.preserveBg !== undefined) setPreserveBg(saved.preserveBg);
|
||||
|
||||
@@ -75,12 +75,25 @@ i18n
|
||||
},
|
||||
});
|
||||
|
||||
// Keep the document's text direction and language in sync with the active
|
||||
// locale — without this, RTL locales (Arabic today, any future RTL addition)
|
||||
// render in an LTR layout and screen readers get the wrong language.
|
||||
function applyDocumentDirection(lng: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.dir = i18n.dir(lng);
|
||||
document.documentElement.lang = lng;
|
||||
}
|
||||
|
||||
// Fetch the bundle whenever a non-English language becomes active — covers
|
||||
// both the initial browser-detected language and later picker switches.
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
applyDocumentDirection(lng);
|
||||
void loadLocale(lng);
|
||||
});
|
||||
if (i18n.language && i18n.language !== 'en') void loadLocale(i18n.language);
|
||||
if (i18n.language) {
|
||||
applyDocumentDirection(i18n.language);
|
||||
if (i18n.language !== 'en') void loadLocale(i18n.language);
|
||||
}
|
||||
|
||||
// Selectable UI languages. Native language names live here, in the i18n
|
||||
// layer — never hardcoded in component code (see the "no hardcoded
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "الإعدادات",
|
||||
"ui_scale": "مقياس واجهة المستخدم",
|
||||
"proxy": "وكيل",
|
||||
"proxy_desc": "وكيل HTTP/SOCKS5 للتنزيلات (yt-dlp، HuggingFace). يدعم http:// وhttps:// وsocks5://. يلزم إعادة التشغيل إذا تم تغييره بعد بدء الواجهة الخلفية.",
|
||||
"proxy_saved": "مجموعة الوكيل - سوف يستخدمها yt-dlp للتنزيلات",
|
||||
"proxy_desc": "وكيل HTTP/SOCKS5 للتنزيلات (تنزيلات الفيديو، Hugging Face). يدعم http:// وhttps:// وsocks5://. يُطبَّق على التنزيلات الجديدة فورًا.",
|
||||
"proxy_saved": "تم ضبط الوكيل — ستستخدمه التنزيلات الجديدة",
|
||||
"proxy_save_failed": "فشل حفظ الوكيل",
|
||||
"proxy_cleared": "تم مسح الوكيل",
|
||||
"proxy_clear": "واضح",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "مقدمو خدمات الترجمة",
|
||||
"translation_providers_desc": "مفاتيح واجهة برمجة التطبيقات (API) للمترجمين عبر الإنترنت، مُعيَّنة لهذه الجلسة.",
|
||||
"review_mode": "وضع المراجعة",
|
||||
"review_mode_on": "مراجعة",
|
||||
"review_mode_off": "سريع النيران",
|
||||
"review_mode_desc": "توقف مؤقتًا بين مراحل التدفق حتى تتمكن من مراجعة مخرجات ASR/الترجمة.",
|
||||
"header_live_stats": "عرض مقاييس النظام المباشرة في الرأس",
|
||||
"header_live_stats_desc": "يضيف شاشة RAM / CPU / VRAM مباشرة إلى الشريط العلوي (يتم إيقاف تشغيله افتراضيًا).",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "التحقق من وجود تحديثات",
|
||||
"copy_diagnostics": "نسخ التشخيص",
|
||||
"github": "أومنيفويس على جيثب",
|
||||
"model_card": "البطاقة النموذجية",
|
||||
"commercial_license": "رخصة تجارية",
|
||||
"update_channel": "قناة التحديث",
|
||||
"channel_stable": "مستقر",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "نقطة النهاية المخصصة لمترجم مايكروسوفت"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "مراجعة",
|
||||
"review_off": "سريع النيران",
|
||||
"banners_on": "لافتات المرحلة على",
|
||||
"banners_off": "لافتات المرحلة قبالة",
|
||||
"backend": "الخلفية",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "ممكّن",
|
||||
"add": "إضافة إدخال",
|
||||
"remove": "إزالة {{term}}",
|
||||
"empty": "لا توجد إدخالات حتى الآن. أضف واحدًا أعلاه لإصلاح كلمة تم نطقها بشكل خاطئ.",
|
||||
"empty": "لا توجد إدخالات حتى الآن. أضف واحدًا أدناه لإصلاح كلمة تم نطقها بشكل خاطئ.",
|
||||
"test_label": "اختبار جملة",
|
||||
"test_placeholder": "اكتب جملة لمعاينة الاستبدال...",
|
||||
"test_result": "سيتم التحدث على النحو التالي:",
|
||||
"test_nochange": "لا توجد إدخالات متطابقة - منطوقة كما هي مكتوبة.",
|
||||
"load_error": "تعذر تحميل قاموس النطق.",
|
||||
"save_error": "تعذر حفظ هذا الإدخال.",
|
||||
"lang_label": "رمز اللغة (على سبيل المثال، en، de) أو اتركه فارغًا لـ Global"
|
||||
"lang_label": "رمز اللغة (على سبيل المثال، en، de) أو اتركه فارغًا لـ Global",
|
||||
"enable_entry": "تفعيل {{term}}",
|
||||
"lang_placeholder": "en, de… (فارغ = عالمي)",
|
||||
"test_language": "لغة المعاينة",
|
||||
"test_global_hint": "تتم معاينة الإدخالات العالمية فقط — اختر لغة لتضمين الإدخالات الخاصة بلغة معينة.",
|
||||
"test_error": "المعاينة غير متاحة — تعذر الوصول إلى الخادم الخلفي.",
|
||||
"backup_title": "النسخ الاحتياطي والاستعادة",
|
||||
"backup_hint": "نزّل القاموس كملف JSON، أو استورد إدخالات من تصدير سابق.",
|
||||
"export": "تصدير JSON",
|
||||
"import": "استيراد JSON",
|
||||
"import_replace_prompt": "هل تريد استبدال الإدخالات الحالية وعددها {{count}} بالملف المستورد؟ اختر لا لإضافة الإدخالات المستوردة إلى جانب الحالية.",
|
||||
"import_done": "تم استيراد {{count}} من الإدخالات.",
|
||||
"import_error": "تعذر استيراد هذا الملف — المتوقع هو JSON من تصدير النطق.",
|
||||
"export_error": "تعذر تصدير القاموس."
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "كتاب مسموع",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "Einstellungen",
|
||||
"ui_scale": "UI-Skala",
|
||||
"proxy": "Stellvertreter",
|
||||
"proxy_desc": "HTTP/SOCKS5-Proxy für Downloads (yt-dlp, HuggingFace). Unterstützt http://, https://, sock5://. Bei Änderung nach dem Backend-Start ist ein Neustart erforderlich.",
|
||||
"proxy_saved": "Proxy-Satz – yt-dlp wird ihn für Downloads verwenden",
|
||||
"proxy_desc": "HTTP/SOCKS5-Proxy für Downloads (Video-Downloads, Hugging Face). Unterstützt http://, https://, socks5://. Gilt sofort für neue Downloads.",
|
||||
"proxy_saved": "Proxy gesetzt — neue Downloads verwenden ihn",
|
||||
"proxy_save_failed": "Proxy konnte nicht gespeichert werden",
|
||||
"proxy_cleared": "Proxy gelöscht",
|
||||
"proxy_clear": "Klar",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "Übersetzungsanbieter",
|
||||
"translation_providers_desc": "API-Schlüssel für Online-Übersetzer, festgelegt für diese Sitzung.",
|
||||
"review_mode": "Überprüfungsmodus",
|
||||
"review_mode_on": "Rezension",
|
||||
"review_mode_off": "Schnellfeuer",
|
||||
"review_mode_desc": "Machen Sie eine Pause zwischen den Pipeline-Stufen, damit Sie die ASR-/Übersetzungsausgabe überprüfen können.",
|
||||
"header_live_stats": "Live-Systemmetriken in der Kopfzeile anzeigen",
|
||||
"header_live_stats_desc": "Fügt der oberen Leiste einen Live-RAM-/CPU-/VRAM-Monitor hinzu (standardmäßig deaktiviert).",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "Suchen Sie nach Updates",
|
||||
"copy_diagnostics": "Diagnose kopieren",
|
||||
"github": "OmniVoice auf GitHub",
|
||||
"model_card": "Modellkarte",
|
||||
"commercial_license": "Kommerzielle Lizenz",
|
||||
"update_channel": "Update-Kanal",
|
||||
"channel_stable": "Stabil",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "Benutzerdefinierter Microsoft Translator-Endpunkt"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Rezension",
|
||||
"review_off": "Schnellfeuer",
|
||||
"banners_on": "Bühnenbanner auf",
|
||||
"banners_off": "Bühnenbanner abgehängt",
|
||||
"backend": "Backend",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "Aktiviert",
|
||||
"add": "Eintrag hinzufügen",
|
||||
"remove": "{{term}} entfernen",
|
||||
"empty": "Noch keine Einträge. Fügen Sie oben eines hinzu, um ein falsch ausgesprochenes Wort zu korrigieren.",
|
||||
"empty": "Noch keine Einträge. Fügen Sie unten einen hinzu, um ein falsch ausgesprochenes Wort zu korrigieren.",
|
||||
"test_label": "Testen Sie einen Satz",
|
||||
"test_placeholder": "Geben Sie einen Satz ein, um eine Vorschau der Ersetzung anzuzeigen ...",
|
||||
"test_result": "Wird gesprochen als:",
|
||||
"test_nochange": "Keine Einträge stimmen überein – gesprochen wie geschrieben.",
|
||||
"load_error": "Das Aussprachewörterbuch konnte nicht geladen werden.",
|
||||
"save_error": "Dieser Eintrag konnte nicht gespeichert werden.",
|
||||
"lang_label": "Sprachcode (z. B. en, de) oder bei Global leer lassen"
|
||||
"lang_label": "Sprachcode (z. B. en, de) oder bei Global leer lassen",
|
||||
"enable_entry": "{{term}} aktivieren",
|
||||
"lang_placeholder": "en, de… (leer = Global)",
|
||||
"test_language": "Vorschausprache",
|
||||
"test_global_hint": "Vorschau nur mit globalen Einträgen — wählen Sie eine Sprache, um sprachspezifische Einträge einzubeziehen.",
|
||||
"test_error": "Vorschau nicht verfügbar — das Backend war nicht erreichbar.",
|
||||
"backup_title": "Sichern & Wiederherstellen",
|
||||
"backup_hint": "Laden Sie das Wörterbuch als JSON-Datei herunter oder importieren Sie Einträge aus einem früheren Export.",
|
||||
"export": "JSON exportieren",
|
||||
"import": "JSON importieren",
|
||||
"import_replace_prompt": "Die {{count}} vorhandenen Einträge durch die importierte Datei ersetzen? Wählen Sie Nein, um die importierten Einträge zusätzlich hinzuzufügen.",
|
||||
"import_done": "{{count}} Einträge importiert.",
|
||||
"import_error": "Diese Datei konnte nicht importiert werden — erwartet wird JSON aus einem Aussprache-Export.",
|
||||
"export_error": "Das Wörterbuch konnte nicht exportiert werden."
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "Hörbuch",
|
||||
|
||||
@@ -15,14 +15,27 @@
|
||||
"enabled": "Enabled",
|
||||
"add": "Add entry",
|
||||
"remove": "Remove {{term}}",
|
||||
"empty": "No entries yet. Add one above to fix a mispronounced word.",
|
||||
"empty": "No entries yet. Add one below to fix a mispronounced word.",
|
||||
"test_label": "Test a sentence",
|
||||
"test_placeholder": "Type a sentence to preview the substitution…",
|
||||
"test_result": "Will be spoken as:",
|
||||
"test_nochange": "No entries match — spoken as written.",
|
||||
"load_error": "Couldn't load the pronunciation dictionary.",
|
||||
"save_error": "Couldn't save that entry.",
|
||||
"lang_label": "Language code (e.g. en, de) or leave blank for Global"
|
||||
"lang_label": "Language code (e.g. en, de) or leave blank for Global",
|
||||
"enable_entry": "Enable {{term}}",
|
||||
"lang_placeholder": "en, de… (blank = Global)",
|
||||
"test_language": "Preview language",
|
||||
"test_global_hint": "Previewing Global entries only — pick a language to include language-scoped entries.",
|
||||
"test_error": "Preview unavailable — couldn't reach the backend.",
|
||||
"backup_title": "Backup & restore",
|
||||
"backup_hint": "Download the dictionary as a JSON file, or import entries from a previous export.",
|
||||
"export": "Export JSON",
|
||||
"import": "Import JSON",
|
||||
"import_replace_prompt": "Replace the {{count}} existing entries with the imported file? Choose No to add the imported entries alongside them.",
|
||||
"import_done": "Imported {{count}} entries.",
|
||||
"import_error": "Couldn't import that file — expected JSON from a pronunciation export.",
|
||||
"export_error": "Couldn't export the dictionary."
|
||||
},
|
||||
"update": {
|
||||
"available": "Update {{version}} available",
|
||||
@@ -310,7 +323,7 @@
|
||||
"general": "General",
|
||||
"privacy": "Privacy",
|
||||
"about": "About",
|
||||
"ui_scale": "UI Scale",
|
||||
"ui_scale": "UI scale",
|
||||
"theme": "Theme",
|
||||
"color_theme": "Color theme",
|
||||
"font": "Font",
|
||||
@@ -385,17 +398,64 @@
|
||||
"llmskills_dictation_refinement_desc": "Cleans final dictation transcripts — filler words, self-corrections, punctuation. Off: the raw transcript passes through unchanged.",
|
||||
"updates": "Updates",
|
||||
"proxy": "Proxy",
|
||||
"proxy_desc": "HTTP/SOCKS5 proxy for downloads (yt-dlp, HuggingFace). Supports http://, https://, socks5://. Restart required if changed after backend start.",
|
||||
"proxy_saved": "Proxy set — yt-dlp will use it for downloads",
|
||||
"proxy_desc": "HTTP/SOCKS5 proxy for downloads (video downloads, Hugging Face). Supports http://, https://, socks5://. Applies to new downloads immediately.",
|
||||
"proxy_saved": "Proxy set — new downloads will use it",
|
||||
"proxy_save_failed": "Failed to save proxy",
|
||||
"proxy_cleared": "Proxy cleared",
|
||||
"proxy_clear": "Clear",
|
||||
"proxy_input_aria": "Proxy URL",
|
||||
"ffmpeg_input_aria": "FFmpeg path",
|
||||
"remote_backend_title": "Remote backend",
|
||||
"remote_backend_desc": "Run inference on another machine; leave the URL empty for the local backend. Saving reloads the app to apply.",
|
||||
"remote_backend_hint": "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.",
|
||||
"remote_backend_url": "Backend URL",
|
||||
"remote_backend_key": "API key",
|
||||
"remote_backend_key_placeholder": "value of OMNIVOICE_API_KEY on the server",
|
||||
"remote_backend_test": "Test connection",
|
||||
"remote_backend_save": "Save & reload",
|
||||
"remote_backend_probe_ok": "OK — {{detail}}",
|
||||
"remote_backend_probe_fail": "Failed — {{detail}}",
|
||||
"remote_backend_unreachable": "unreachable",
|
||||
"remote_backend_invalid_url": "Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).",
|
||||
"remote_backend_confirm_unverified": "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.",
|
||||
"remote_backend_confirm_title": "Use unverified backend?",
|
||||
"ffmpeg": "FFmpeg",
|
||||
"ffmpeg_found": "Found",
|
||||
"ffmpeg_missing": "Not found",
|
||||
"ffmpeg_current": "Current path",
|
||||
"ffmpeg_desc": "Set a custom ffmpeg path if auto-detection fails.",
|
||||
"ffmpeg_saved": "FFmpeg path set — restart backend to apply.",
|
||||
"audio_tools": "Audio tools",
|
||||
"audio_tools_desc": "The media engine (FFmpeg, FFprobe) and video downloader (yt-dlp) the app manages for you.",
|
||||
"audio_tools_origin_bundled": "Bundled",
|
||||
"audio_tools_origin_system": "System",
|
||||
"audio_tools_origin_custom": "Custom",
|
||||
"audio_tools_origin_sidecar": "App package",
|
||||
"audio_tools_not_found": "Not available",
|
||||
"audio_tools_version_unknown": "version unknown",
|
||||
"audio_tools_use_system": "Use system copy",
|
||||
"audio_tools_choose_file": "Choose file…",
|
||||
"audio_tools_restore": "Restore bundled",
|
||||
"audio_tools_update_bundle": "Update bundled build",
|
||||
"audio_tools_bundle_updating": "Downloading bundled build… {{percent}}%",
|
||||
"audio_tools_bundle_done": "Bundled media engine ready.",
|
||||
"audio_tools_bundle_failed": "Bundled download failed: {{message}}",
|
||||
"audio_tools_path_set": "{{tool}} now uses {{path}}",
|
||||
"audio_tools_path_failed": "Couldn't set path: {{message}}",
|
||||
"audio_tools_restored": "{{tool}} restored to the app-managed build.",
|
||||
"audio_tools_path_input_aria": "{{tool}} binary path",
|
||||
"audio_tools_manual_hint": "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.",
|
||||
"audio_tools_ffmpeg_desc": "Converts, dubs, and mixes audio/video. The app resolves it automatically — override only if you need a specific build.",
|
||||
"audio_tools_ffprobe_desc": "Reads media metadata (durations, frame rates) for Smart Fit and imports.",
|
||||
"audio_tools_ytdlp": "yt-dlp (video downloader)",
|
||||
"audio_tools_ytdlp_desc": "Powers video/clip imports. Site support changes faster than app releases — update it here when imports start failing.",
|
||||
"audio_tools_ytdlp_update": "Update",
|
||||
"audio_tools_ytdlp_updated": "yt-dlp {{version}} installed — restart the backend to apply.",
|
||||
"audio_tools_ytdlp_update_failed": "yt-dlp update failed: {{message}}",
|
||||
"audio_tools_ytdlp_restore": "Restore tested version",
|
||||
"audio_tools_ytdlp_restored": "Tested yt-dlp restored — restart the backend to apply.",
|
||||
"audio_tools_moved_note": "The FFmpeg override moved to its own panel with more control (version, origin, restore).",
|
||||
"audio_tools_open": "Open Audio tools",
|
||||
"diagnostics_copied": "Diagnostics copied — paste into your issue report.",
|
||||
"updater_desktop": "Updater only runs in the desktop app.",
|
||||
"latest_version": "You're on the latest version.",
|
||||
@@ -455,6 +515,27 @@
|
||||
"hf_token_load_error": "Failed to load token state",
|
||||
"hf_token_save_error": "Failed to save token",
|
||||
"hf_token_clear_error": "Failed to clear token",
|
||||
"hf_token_checking": "Checking token sources…",
|
||||
"retry": "Retry",
|
||||
"llmskills_route_for": "Provider for {{skill}}",
|
||||
"mcp_title": "MCP voice bindings",
|
||||
"mcp_desc": "Give each MCP agent its own voice — bind the client id an agent sends to a voice profile.",
|
||||
"mcp_hint": "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.",
|
||||
"mcp_empty": "No bindings yet — add an agent's client id below.",
|
||||
"mcp_load_failed": "Failed to load MCP bindings",
|
||||
"mcp_save_failed": "Failed to save binding",
|
||||
"mcp_delete_failed": "Failed to delete binding",
|
||||
"mcp_delete_confirm": "Remove the voice binding for “{{clientId}}”?",
|
||||
"mcp_delete_confirm_title": "Remove binding",
|
||||
"mcp_remove": "Remove {{clientId}}",
|
||||
"mcp_add_title": "Add binding",
|
||||
"mcp_add": "Add binding",
|
||||
"mcp_client_id": "Client ID",
|
||||
"mcp_client_id_placeholder": "Client ID (e.g. claude-code)",
|
||||
"mcp_label": "Label",
|
||||
"mcp_label_placeholder": "Label (optional)",
|
||||
"mcp_voice_profile": "Voice profile",
|
||||
"mcp_default_voice": "Default voice",
|
||||
"advanced": "Advanced",
|
||||
"shortcut": "Dictation shortcut",
|
||||
"credentials_desc": "API keys and tokens, set for this session.",
|
||||
@@ -464,6 +545,7 @@
|
||||
"group_system": "System",
|
||||
"group_app": "App",
|
||||
"search_placeholder": "Search settings…",
|
||||
"search_no_results": "No settings match “{{query}}”",
|
||||
"restart_required": "Restart required",
|
||||
"applies_now": "Applies now",
|
||||
"dictation": "Dictation",
|
||||
@@ -474,7 +556,7 @@
|
||||
"storage": "Storage",
|
||||
"device": "Device & compute",
|
||||
"device_desc": "Live hardware and backend readouts.",
|
||||
"network_desc": "Proxy and FFmpeg paths for downloads and media processing.",
|
||||
"network_desc": "Proxy for downloads and model fetches.",
|
||||
"translation_desc": "How dubbing translates dialogue, and which engine does it.",
|
||||
"translate_quality": "Translation quality",
|
||||
"translate_quality_desc": "Fast is literal and quick; Cinematic uses the LLM for natural phrasing.",
|
||||
@@ -489,6 +571,8 @@
|
||||
"translation_llm_row_desc": "Choose, test, and activate a provider (OpenAI, OpenRouter, Groq, a local Ollama, …) in LLM Providers. Keys are stored encrypted; local providers stay fully offline.",
|
||||
"translation_open_llm": "Open LLM Providers",
|
||||
"review_mode": "Review mode",
|
||||
"review_mode_on": "Pause for review",
|
||||
"review_mode_off": "Run straight through",
|
||||
"review_mode_desc": "Pause between pipeline stages so you can review ASR / translation output.",
|
||||
"header_live_stats": "Show live system metrics in header",
|
||||
"header_live_stats_desc": "Adds a live RAM / CPU / VRAM monitor to the top bar (off by default).",
|
||||
@@ -500,7 +584,7 @@
|
||||
"factory_reset_confirm": "Reset and reload",
|
||||
"factory_reset_confirm_body": "This clears all saved UI preferences and reloads the app. Your voices, projects, and outputs on disk are not affected. Continue?",
|
||||
"factory_reset_done": "Preferences cleared — reloading…",
|
||||
"factory_reset_failed": "Reset failed",
|
||||
"factory_reset_failed": "Reset failed: {{message}}",
|
||||
"storage_usage": "Disk usage",
|
||||
"storage_usage_desc": "What OmniVoice stores on this machine, and how much space is left.",
|
||||
"storage_refresh": "Refresh",
|
||||
@@ -538,7 +622,23 @@
|
||||
"history_retention_cap_hint": "Starred takes never count against cleanup · 0 = unlimited",
|
||||
"history_retention_saved": "Retention limit saved",
|
||||
"history_retention_save_failed": "Could not save",
|
||||
"history_retention_invalid": "Enter 0 or more"
|
||||
"history_retention_invalid": "Enter 0 or more",
|
||||
"perf_title": "Performance",
|
||||
"perf_torch_compile": "Disable torch.compile (Windows)",
|
||||
"perf_torch_compile_na": "Windows only — not needed on this platform",
|
||||
"perf_torch_compile_note": "Falls back to eager mode — fixes Triton OOM on <16 GB GPUs.",
|
||||
"perf_torch_compile_hint": "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.",
|
||||
"perf_load_failed": "Failed to load performance settings",
|
||||
"perf_save_failed": "Failed to save setting",
|
||||
"storage_load_failed": "Failed to load storage info",
|
||||
"open_folder_failed": "Could not open folder",
|
||||
"storage_clear_temp": "Clear temp files",
|
||||
"storage_clear_temp_hint": "Delete OmniVoice's own files in the temp directory",
|
||||
"storage_clear_temp_confirm": "Delete OmniVoice's temporary working files? Don't do this while a dub or batch job is running.",
|
||||
"storage_clear_temp_failed": "Could not clear temporary files",
|
||||
"storage_temp_cleared": "Temporary files cleared — {{freed}} freed",
|
||||
"data_dir_at": "App data stored at",
|
||||
"history_retention_load_failed": "Could not load the current retention limit"
|
||||
},
|
||||
"about": {
|
||||
"app": "App",
|
||||
@@ -572,9 +672,9 @@
|
||||
"check_updates": "Check for updates",
|
||||
"copy_diagnostics": "Copy diagnostics",
|
||||
"github": "OmniVoice on GitHub",
|
||||
"model_card": "Model card",
|
||||
"commercial_license": "Commercial License",
|
||||
"commercial_license": "Commercial license",
|
||||
"self_check": "Run self-check",
|
||||
"open_fix_category": "Open {{category}}",
|
||||
"self_check_failed": "Self-check failed: {{message}}",
|
||||
"self_check_ok": "OK",
|
||||
"self_check_warn": "Warning",
|
||||
@@ -595,6 +695,8 @@
|
||||
"local_sqlite": "Local SQLite",
|
||||
"translator_online": "Translator is online: {{provider}}",
|
||||
"translator_offline": "Offline translator",
|
||||
"translator_unknown": "Unknown",
|
||||
"change_translator": "Change translator",
|
||||
"no_tracking": "None — no tracking"
|
||||
},
|
||||
"credentials": {
|
||||
@@ -631,7 +733,8 @@
|
||||
},
|
||||
"capture": {
|
||||
"desc": "Global hotkeys only work in the desktop app. The web UI uses an in-page <1>Ctrl+Shift+Space</1> shortcut while the window has focus.",
|
||||
"desc_detail": "The hotkey works system-wide while OmniVoice is running — it focuses the window and starts dictation. Avoid combos already claimed by the OS (on macOS, <1>⌘+Space</1> is Spotlight and <2>⌘+⇧+Space</2> cycles input sources). If registration fails, pick a different combo.",
|
||||
"desc_detail": "The hotkey works system-wide while OmniVoice is running — it focuses the window and starts dictation. Avoid combos already claimed by the OS (on macOS, <1>⌘+Space</1> is Spotlight and <2>⌘+⇧+Space</2> cycles input sources). If registration fails, pick a different combo. A shortcut needs at least one modifier (Ctrl / Cmd / Alt / Shift) combined with a regular key.",
|
||||
"needs_modifier": "Add a modifier — Ctrl / Cmd / Alt / Shift + key (Esc to cancel)",
|
||||
"active_shortcut": "Active shortcut",
|
||||
"new_shortcut": "New shortcut",
|
||||
"press_key": "Press a key combo…",
|
||||
@@ -1285,7 +1388,15 @@
|
||||
"system_check": "System check",
|
||||
"install_models": "Install models",
|
||||
"pick_engines": "Pick engines",
|
||||
"system_check_desc": "Probe RAM, disk, GPU, ffmpeg, and network. Blockers are flagged upfront so you know before downloading.",
|
||||
"system_check_desc": "Probe RAM, disk, GPU, and network. Blockers are flagged upfront so you know before downloading.",
|
||||
"media_engine_preparing": "Preparing media engine…",
|
||||
"media_engine_failed_title": "Media engine download failed",
|
||||
"media_engine_failed_desc": "The app couldn't fetch its bundled audio/video engine (FFmpeg). Retry, or point it at a copy already on this computer.",
|
||||
"media_engine_retry": "Retry",
|
||||
"media_engine_use_system": "Use a system copy",
|
||||
"media_engine_detect_failed": "No system copy found — retry the download or choose the file manually.",
|
||||
"media_engine_choose_file": "Choose file…",
|
||||
"media_engine_ready": "Media engine configured.",
|
||||
"install_models_desc": "Download ~5 GB of weights — TTS + Whisper. Required models first, optional ones later.",
|
||||
"pick_engines_desc": "Choose TTS / ASR / LLM backends. Defaults work out of the box — customize anytime in Settings.",
|
||||
"hero_desc": "Dubbing, voice cloning, and voice design — all running locally on your machine.",
|
||||
@@ -1562,8 +1673,6 @@
|
||||
"taxonomyTokens": "taxonomy tokens"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Review",
|
||||
"review_off": "Rapid-fire",
|
||||
"banners_on": "Stage banners on",
|
||||
"banners_off": "Stage banners off",
|
||||
"backend": "Backend",
|
||||
@@ -1575,6 +1684,10 @@
|
||||
"refresh": "Refresh",
|
||||
"matrixTitle": "Engine Compatibility Matrix",
|
||||
"familyMatrixTitle": "{{family}} Engines",
|
||||
"colEngine": "Engine",
|
||||
"colGpuCompat": "GPU compat",
|
||||
"colIsolation": "Isolation",
|
||||
"colActions": "Actions",
|
||||
"loadFailed": "Failed to load engines: {{message}}",
|
||||
"couldNotLoad": "Could not load engines: {{message}}",
|
||||
"retry": "Retry",
|
||||
@@ -1616,7 +1729,17 @@
|
||||
"routingCaveatTitle": "GPU selected, but: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: running on CPU — {{reason}}",
|
||||
"curatedModelLabel": "Model",
|
||||
"curatedModelAria": "Model for {{engine}}"
|
||||
"curatedModelAria": "Model for {{engine}}",
|
||||
"cloneCapable": "Voice cloning",
|
||||
"cloneCapableTitle": "Can clone a voice from a short reference clip",
|
||||
"inMemory": "In memory",
|
||||
"inMemoryTitle": "Loaded in memory right now — unloading frees RAM/VRAM and it reloads on next use",
|
||||
"unload": "Unload",
|
||||
"unloading": "Unloading…",
|
||||
"unloadFailed": "Could not unload: {{message}}",
|
||||
"familyDesc_tts": "Turns your script into speech. The engine marked active is the one Studio, Dubbing and Batch use.",
|
||||
"familyDesc_asr": "Turns audio into text — transcription for dubbing, captions and dictation.",
|
||||
"familyDesc_llm": "Optional text helper for translation and rewrites. \"Off\" simply skips those steps."
|
||||
},
|
||||
"errors": {
|
||||
"title": "This tab hit a snag.",
|
||||
@@ -1671,7 +1794,8 @@
|
||||
"no": "No",
|
||||
"more_info": "More info",
|
||||
"learn_more": "Learn more",
|
||||
"saving": "Saving…"
|
||||
"saving": "Saving…",
|
||||
"unknown": "unknown"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Keyboard shortcuts",
|
||||
@@ -1879,7 +2003,10 @@
|
||||
"log_copied": "Copied {{source}} log",
|
||||
"copy_failed": "Copy failed: {{message}}",
|
||||
"report_copied": "Diagnostic report copied — paste it into a GitHub issue.",
|
||||
"report_failed": "Report failed: {{message}}"
|
||||
"report_failed": "Report failed: {{message}}",
|
||||
"source": "Log source",
|
||||
"frontend_buffer": "in-memory (last 500)",
|
||||
"copy_failed_short": "Could not copy the log"
|
||||
},
|
||||
"trimmer": {
|
||||
"title": "Trim reference audio",
|
||||
@@ -2024,6 +2151,15 @@
|
||||
"search_placeholder": "Search models…",
|
||||
"search_label": "Search models",
|
||||
"no_matches": "No models match your filters.",
|
||||
"clear_filters": "Clear filters",
|
||||
"in_memory": "In memory",
|
||||
"in_memory_title": "Loaded in memory right now — unloading frees RAM/VRAM and it reloads on next use",
|
||||
"unload_btn": "Unload",
|
||||
"unload_aria": "Unload {{repoId}} from memory",
|
||||
"unloaded_toast": "Unloaded — memory freed",
|
||||
"unload_failed": "Could not unload: {{message}}",
|
||||
"reco_disk_free": "{{free}} GB free on the model disk",
|
||||
"reco_low_disk": "Not enough space for the full bundle: it needs ~{{need}} GB but only {{free}} GB is free. Install just the required models, or free up space first.",
|
||||
"sort_by": "Sort by {{column}}",
|
||||
"column_model": "Model",
|
||||
"column_role": "Role",
|
||||
@@ -2047,6 +2183,10 @@
|
||||
"mirror_restart_note": "Model Store downloads use the new mirror immediately. Only model loads (transformers) pick it up after a restart.",
|
||||
"mirror_load_error": "Failed to load mirror setting",
|
||||
"mirror_save_error": "Failed to save",
|
||||
"mirror_custom_url": "Custom mirror URL",
|
||||
"mirror_custom_url_note": "Sets the HF_ENDPOINT environment variable for Hugging Face downloads.",
|
||||
"mirror_saved": "Mirror setting saved",
|
||||
"mirror_retry": "Retry",
|
||||
"asrOpenAICompatTitle": "OpenAI-compatible ASR (remote server)",
|
||||
"asrOpenAICompatDescription": "Point transcription at Qwen3-ASR, a self-hosted FunASR/SenseVoice server, or OpenAI's own API.",
|
||||
"asrOpenAICompatBaseUrlTitle": "Server URL",
|
||||
@@ -2057,7 +2197,8 @@
|
||||
"asrOpenAICompatApiKeyOptional": "optional",
|
||||
"asrOpenAICompatKeyConfigured": "A key is saved. Leave blank to keep it, or type a new one to replace it.",
|
||||
"asrOpenAICompatLoadError": "Failed to load ASR server setting",
|
||||
"asrOpenAICompatSaveError": "Failed to save"
|
||||
"asrOpenAICompatSaveError": "Failed to save",
|
||||
"asrOpenAICompatSaved": "Saved"
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Do I need a license for internal tools?",
|
||||
@@ -2251,6 +2392,7 @@
|
||||
"copy_url": "Copy spec URL",
|
||||
"copy_url_aria": "Copy the /openapi.json URL",
|
||||
"copied": "Spec URL copied",
|
||||
"copy_failed": "Copy failed — select and copy the URL above manually.",
|
||||
"open_raw": "Open raw spec",
|
||||
"open_raw_aria": "Open the raw OpenAPI JSON in your browser"
|
||||
},
|
||||
@@ -2391,5 +2533,29 @@
|
||||
"cleaned_loaded": "Recording cleaned & loaded!",
|
||||
"loaded_raw": "Recording loaded (raw — denoising unavailable)",
|
||||
"too_short": "Recording too short"
|
||||
},
|
||||
"dictation": {
|
||||
"title": "Dictation refinement",
|
||||
"needs_llm": "Needs a local LLM endpoint — until then, raw transcripts paste unchanged.",
|
||||
"no_llm_configured": "no LLM configured",
|
||||
"open_llm_providers": "Open LLM Providers",
|
||||
"load_error": "Failed to load refinement settings",
|
||||
"save_error": "Failed to save setting",
|
||||
"retry": "Retry",
|
||||
"flag_auto": "Refine dictation with the local LLM",
|
||||
"flag_auto_hint": "Master switch — applied to final transcripts only, never live partials. The raw transcript is always kept in History.",
|
||||
"flag_smart_cleanup": "Remove filler words & add punctuation",
|
||||
"flag_smart_cleanup_hint": "\"so um like the meeting is at 3pm you know\" → \"So the meeting is at 3pm.\"",
|
||||
"flag_self_correction": "Apply spoken self-corrections",
|
||||
"flag_self_correction_hint": "\"at seven no actually six am\" → \"at six am\"",
|
||||
"flag_preserve_technical": "Preserve technical terms & spoken symbols",
|
||||
"flag_preserve_technical_hint": "\"index dot tsx\" → \"index.tsx\"; identifiers stay verbatim",
|
||||
"refine_timeout_note": "The last dictation refinement timed out — the LLM endpoint is slow or unreachable. Dictation still works (the raw transcript is inserted). Test the connection in LLM Providers.",
|
||||
"refine_failed_note": "The last dictation refinement failed — the configured LLM endpoint rejected the request. Dictation still works (the raw transcript is inserted). Test the connection in LLM Providers.",
|
||||
"aec_title": "Dictate while audio plays",
|
||||
"aec_description": "Cancel OmniVoice's own playback out of the microphone.",
|
||||
"aec_row_title": "Enable echo cancellation for dictation",
|
||||
"aec_experimental": "Experimental",
|
||||
"aec_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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "Configuración",
|
||||
"ui_scale": "Escala de interfaz de usuario",
|
||||
"proxy": "apoderado",
|
||||
"proxy_desc": "Proxy HTTP/SOCKS5 para descargas (yt-dlp, HuggingFace). Admite http://, https://, calcetines5://. Se requiere reinicio si se cambia después del inicio del backend.",
|
||||
"proxy_saved": "Conjunto de proxy: yt-dlp lo usará para descargas",
|
||||
"proxy_desc": "Proxy HTTP/SOCKS5 para descargas (descargas de vídeo, Hugging Face). Admite http://, https://, socks5://. Se aplica de inmediato a las nuevas descargas.",
|
||||
"proxy_saved": "Proxy configurado — las nuevas descargas lo usarán",
|
||||
"proxy_save_failed": "No se pudo guardar el proxy",
|
||||
"proxy_cleared": "Proxy borrado",
|
||||
"proxy_clear": "Borrar",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "Proveedores de traducción",
|
||||
"translation_providers_desc": "Claves API para traductores en línea, configuradas para esta sesión.",
|
||||
"review_mode": "Modo de revisión",
|
||||
"review_mode_on": "Revisión",
|
||||
"review_mode_off": "Fuego rápido",
|
||||
"review_mode_desc": "Haga una pausa entre las etapas del proceso para que pueda revisar el resultado de ASR/traducción.",
|
||||
"header_live_stats": "Mostrar métricas del sistema en vivo en el encabezado",
|
||||
"header_live_stats_desc": "Agrega un monitor de RAM/CPU/VRAM en vivo a la barra superior (desactivado de forma predeterminada).",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "Buscar actualizaciones",
|
||||
"copy_diagnostics": "Copiar diagnóstico",
|
||||
"github": "OmniVoice en GitHub",
|
||||
"model_card": "tarjeta modelo",
|
||||
"commercial_license": "Licencia Comercial",
|
||||
"update_channel": "Canal de actualización",
|
||||
"channel_stable": "Estable",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "Punto final personalizado de Microsoft Translator"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Revisión",
|
||||
"review_off": "Fuego rápido",
|
||||
"banners_on": "Pancartas del escenario encendidas",
|
||||
"banners_off": "pancartas del escenario apagadas",
|
||||
"backend": "backend",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "Habilitado",
|
||||
"add": "Agregar entrada",
|
||||
"remove": "Eliminar {{term}}",
|
||||
"empty": "Aún no hay entradas. Agregue uno arriba para corregir una palabra mal pronunciada.",
|
||||
"empty": "Aún no hay entradas. Agregue una abajo para corregir una palabra mal pronunciada.",
|
||||
"test_label": "Probar una oración",
|
||||
"test_placeholder": "Escriba una oración para obtener una vista previa de la sustitución...",
|
||||
"test_result": "Se hablará como:",
|
||||
"test_nochange": "Ninguna entrada coincide: hablada tal como está escrita.",
|
||||
"load_error": "No se pudo cargar el diccionario de pronunciación.",
|
||||
"save_error": "No se pudo guardar esa entrada.",
|
||||
"lang_label": "Código de idioma (por ejemplo, en, de) o déjelo en blanco para Global"
|
||||
"lang_label": "Código de idioma (por ejemplo, en, de) o déjelo en blanco para Global",
|
||||
"enable_entry": "Activar {{term}}",
|
||||
"lang_placeholder": "en, de… (vacío = Mundial)",
|
||||
"test_language": "Idioma de la vista previa",
|
||||
"test_global_hint": "Vista previa solo con entradas globales — elija un idioma para incluir las entradas por idioma.",
|
||||
"test_error": "Vista previa no disponible — no se pudo contactar el backend.",
|
||||
"backup_title": "Copia de seguridad y restauración",
|
||||
"backup_hint": "Descargue el diccionario como archivo JSON o importe entradas de una exportación anterior.",
|
||||
"export": "Exportar JSON",
|
||||
"import": "Importar JSON",
|
||||
"import_replace_prompt": "¿Reemplazar las {{count}} entradas existentes con el archivo importado? Elija No para añadir las entradas importadas junto a las existentes.",
|
||||
"import_done": "Se importaron {{count}} entradas.",
|
||||
"import_error": "No se pudo importar ese archivo — se esperaba JSON de una exportación de pronunciación.",
|
||||
"export_error": "No se pudo exportar el diccionario."
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "Audiolibro",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "Paramètres",
|
||||
"ui_scale": "Échelle de l'interface utilisateur",
|
||||
"proxy": "Procuration",
|
||||
"proxy_desc": "Proxy HTTP/SOCKS5 pour les téléchargements (yt-dlp, HuggingFace). Prend en charge http://, https://, chaussettes5://. Redémarrage requis en cas de modification après le démarrage du backend.",
|
||||
"proxy_saved": "Ensemble de proxy – yt-dlp l'utilisera pour les téléchargements",
|
||||
"proxy_desc": "Proxy HTTP/SOCKS5 pour les téléchargements (téléchargements vidéo, Hugging Face). Prend en charge http://, https://, socks5://. S'applique immédiatement aux nouveaux téléchargements.",
|
||||
"proxy_saved": "Proxy défini — les nouveaux téléchargements l'utiliseront",
|
||||
"proxy_save_failed": "Échec de l'enregistrement du proxy",
|
||||
"proxy_cleared": "Proxy effacé",
|
||||
"proxy_clear": "Effacer",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "Prestataires de traduction",
|
||||
"translation_providers_desc": "Clés API pour les traducteurs en ligne, définies pour cette session.",
|
||||
"review_mode": "Mode révision",
|
||||
"review_mode_on": "Examen",
|
||||
"review_mode_off": "Tir rapide",
|
||||
"review_mode_desc": "Faites une pause entre les étapes du pipeline afin de pouvoir examiner le résultat ASR/traduction.",
|
||||
"header_live_stats": "Afficher les métriques du système en direct dans l'en-tête",
|
||||
"header_live_stats_desc": "Ajoute un moniteur RAM / CPU / VRAM en direct à la barre supérieure (désactivé par défaut).",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "Vérifier les mises à jour",
|
||||
"copy_diagnostics": "Copier les diagnostics",
|
||||
"github": "OmniVoice sur GitHub",
|
||||
"model_card": "Carte modèle",
|
||||
"commercial_license": "Licence commerciale",
|
||||
"update_channel": "Canal de mise à jour",
|
||||
"channel_stable": "Stable",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "Point de terminaison personnalisé du traducteur Microsoft"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Examen",
|
||||
"review_off": "Tir rapide",
|
||||
"banners_on": "Bannières de scène sur",
|
||||
"banners_off": "Les bannières de la scène sont retirées",
|
||||
"backend": "Back-end",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "Activé",
|
||||
"add": "Ajouter une entrée",
|
||||
"remove": "Supprimer {{term}}",
|
||||
"empty": "Aucune entrée pour l'instant. Ajoutez-en un ci-dessus pour corriger un mot mal prononcé.",
|
||||
"empty": "Aucune entrée pour l'instant. Ajoutez-en une ci-dessous pour corriger un mot mal prononcé.",
|
||||
"test_label": "Tester une phrase",
|
||||
"test_placeholder": "Tapez une phrase pour prévisualiser la substitution…",
|
||||
"test_result": "Sera prononcé comme :",
|
||||
"test_nochange": "Aucune entrée ne correspond – prononcée comme écrite.",
|
||||
"load_error": "Impossible de charger le dictionnaire de prononciation.",
|
||||
"save_error": "Impossible d'enregistrer cette entrée.",
|
||||
"lang_label": "Code de langue (par exemple en, de) ou laissez vide pour Global"
|
||||
"lang_label": "Code de langue (par exemple en, de) ou laissez vide pour Global",
|
||||
"enable_entry": "Activer {{term}}",
|
||||
"lang_placeholder": "en, de… (vide = Mondial)",
|
||||
"test_language": "Langue de l'aperçu",
|
||||
"test_global_hint": "Aperçu avec les entrées globales uniquement — choisissez une langue pour inclure les entrées propres à une langue.",
|
||||
"test_error": "Aperçu indisponible — le backend n'a pas répondu.",
|
||||
"backup_title": "Sauvegarde et restauration",
|
||||
"backup_hint": "Téléchargez le dictionnaire au format JSON ou importez des entrées d'un export précédent.",
|
||||
"export": "Exporter JSON",
|
||||
"import": "Importer JSON",
|
||||
"import_replace_prompt": "Remplacer les {{count}} entrées existantes par le fichier importé ? Choisissez Non pour ajouter les entrées importées aux entrées existantes.",
|
||||
"import_done": "{{count}} entrées importées.",
|
||||
"import_error": "Impossible d'importer ce fichier — JSON d'un export de prononciation attendu.",
|
||||
"export_error": "Impossible d'exporter le dictionnaire."
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "Livre audio",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "सेटिंग्स",
|
||||
"ui_scale": "यूआई स्केल",
|
||||
"proxy": "प्रॉक्सी",
|
||||
"proxy_desc": "डाउनलोड के लिए HTTP/SOCKS5 प्रॉक्सी (yt-dlp, HuggingFace)। http://, https://, socks5:// को सपोर्ट करता है। यदि बैकएंड प्रारंभ के बाद बदला गया है तो पुनः प्रारंभ करना आवश्यक है।",
|
||||
"proxy_saved": "प्रॉक्सी सेट - yt-dlp इसे डाउनलोड के लिए उपयोग करेगा",
|
||||
"proxy_desc": "डाउनलोड के लिए HTTP/SOCKS5 प्रॉक्सी (वीडियो डाउनलोड, Hugging Face)। http://, https://, socks5:// को सपोर्ट करता है। नए डाउनलोड पर तुरंत लागू होता है।",
|
||||
"proxy_saved": "प्रॉक्सी सेट — नए डाउनलोड इसका उपयोग करेंगे",
|
||||
"proxy_save_failed": "प्रॉक्सी सहेजने में विफल",
|
||||
"proxy_cleared": "प्रॉक्सी साफ़ कर दी गई",
|
||||
"proxy_clear": "स्पष्ट",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "अनुवाद प्रदाता",
|
||||
"translation_providers_desc": "इस सत्र के लिए ऑनलाइन अनुवादकों के लिए एपीआई कुंजियाँ निर्धारित की गई हैं।",
|
||||
"review_mode": "समीक्षा मोड",
|
||||
"review_mode_on": "समीक्षा",
|
||||
"review_mode_off": "तेजी से आग",
|
||||
"review_mode_desc": "पाइपलाइन चरणों के बीच रुकें ताकि आप एएसआर/अनुवाद आउटपुट की समीक्षा कर सकें।",
|
||||
"header_live_stats": "हेडर में लाइव सिस्टम मेट्रिक्स दिखाएं",
|
||||
"header_live_stats_desc": "शीर्ष बार में एक लाइव रैम/सीपीयू/वीआरएएम मॉनिटर जोड़ता है (डिफ़ॉल्ट रूप से बंद)।",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "अपडेट के लिए जांचें",
|
||||
"copy_diagnostics": "डायग्नोस्टिक्स की प्रतिलिपि बनाएँ",
|
||||
"github": "GitHub पर ओमनीवॉइस",
|
||||
"model_card": "मॉडल कार्ड",
|
||||
"commercial_license": "वाणिज्यिक लाइसेंस",
|
||||
"update_channel": "अपडेट चैनल",
|
||||
"channel_stable": "स्थिर",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "माइक्रोसॉफ्ट ट्रांसलेटर कस्टम एंडपॉइंट"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "समीक्षा",
|
||||
"review_off": "तेजी से आग",
|
||||
"banners_on": "मंच पर बैनर",
|
||||
"banners_off": "मंच के बैनर उतारे गए",
|
||||
"backend": "बैकएंड",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "सक्षम",
|
||||
"add": "प्रविष्टि जोड़ें",
|
||||
"remove": "{{term}} हटाएं",
|
||||
"empty": "अभी तक कोई प्रविष्टि नहीं. गलत उच्चारण वाले शब्द को ठीक करने के लिए ऊपर एक जोड़ें।",
|
||||
"empty": "अभी तक कोई प्रविष्टि नहीं. गलत उच्चारण वाले शब्द को ठीक करने के लिए नीचे एक जोड़ें।",
|
||||
"test_label": "एक वाक्य का परीक्षण करें",
|
||||
"test_placeholder": "प्रतिस्थापन का पूर्वावलोकन करने के लिए एक वाक्य टाइप करें...",
|
||||
"test_result": "इस प्रकार बोला जाएगा:",
|
||||
"test_nochange": "कोई प्रविष्टियाँ मेल नहीं खातीं - जैसा लिखा है वैसा ही बोला जाता है।",
|
||||
"load_error": "उच्चारण शब्दकोश लोड नहीं किया जा सका.",
|
||||
"save_error": "उस प्रविष्टि को सहेजा नहीं जा सका.",
|
||||
"lang_label": "भाषा कोड (उदा. एन, डी) या ग्लोबल के लिए खाली छोड़ दें"
|
||||
"lang_label": "भाषा कोड (उदा. एन, डी) या ग्लोबल के लिए खाली छोड़ दें",
|
||||
"enable_entry": "{{term}} सक्षम करें",
|
||||
"lang_placeholder": "en, de… (खाली = वैश्विक)",
|
||||
"test_language": "पूर्वावलोकन भाषा",
|
||||
"test_global_hint": "केवल वैश्विक प्रविष्टियों का पूर्वावलोकन — भाषा-विशिष्ट प्रविष्टियाँ शामिल करने के लिए एक भाषा चुनें।",
|
||||
"test_error": "पूर्वावलोकन उपलब्ध नहीं — बैकएंड से संपर्क नहीं हो सका।",
|
||||
"backup_title": "बैकअप और पुनर्स्थापना",
|
||||
"backup_hint": "शब्दकोश को JSON फ़ाइल के रूप में डाउनलोड करें, या पिछले निर्यात से प्रविष्टियाँ आयात करें।",
|
||||
"export": "JSON निर्यात करें",
|
||||
"import": "JSON आयात करें",
|
||||
"import_replace_prompt": "मौजूदा {{count}} प्रविष्टियों को आयातित फ़ाइल से बदलें? आयातित प्रविष्टियों को मौजूदा के साथ जोड़ने के लिए 'नहीं' चुनें।",
|
||||
"import_done": "{{count}} प्रविष्टियाँ आयात की गईं।",
|
||||
"import_error": "वह फ़ाइल आयात नहीं हो सकी — उच्चारण निर्यात की JSON अपेक्षित थी।",
|
||||
"export_error": "शब्दकोश निर्यात नहीं हो सका।"
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "ऑडियोबुक",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "Pengaturan",
|
||||
"ui_scale": "Skala UI",
|
||||
"proxy": "Proksi",
|
||||
"proxy_desc": "Proksi HTTP/SOCKS5 untuk unduhan (yt-dlp, HuggingFace). Mendukung http://, https://, kaus kaki5://. Mulai ulang diperlukan jika diubah setelah backend dimulai.",
|
||||
"proxy_saved": "Kumpulan proxy — yt-dlp akan menggunakannya untuk mengunduh",
|
||||
"proxy_desc": "Proksi HTTP/SOCKS5 untuk unduhan (unduhan video, Hugging Face). Mendukung http://, https://, socks5://. Langsung berlaku untuk unduhan baru.",
|
||||
"proxy_saved": "Proksi disetel — unduhan baru akan menggunakannya",
|
||||
"proxy_save_failed": "Gagal menyimpan proksi",
|
||||
"proxy_cleared": "Proksi dihapus",
|
||||
"proxy_clear": "Jelas",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "Penyedia terjemahan",
|
||||
"translation_providers_desc": "Kunci API untuk penerjemah online, disetel untuk sesi ini.",
|
||||
"review_mode": "Modus tinjauan",
|
||||
"review_mode_on": "Ulasan",
|
||||
"review_mode_off": "Tembakan cepat",
|
||||
"review_mode_desc": "Jeda di antara tahapan alur sehingga Anda dapat meninjau ASR/output terjemahan.",
|
||||
"header_live_stats": "Tampilkan metrik sistem langsung di header",
|
||||
"header_live_stats_desc": "Menambahkan monitor RAM/CPU/VRAM langsung ke bilah atas (dinonaktifkan secara default).",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "Periksa pembaruan",
|
||||
"copy_diagnostics": "Salin diagnostik",
|
||||
"github": "OmniVoice di GitHub",
|
||||
"model_card": "Kartu model",
|
||||
"commercial_license": "Lisensi Komersial",
|
||||
"update_channel": "Saluran pembaruan",
|
||||
"channel_stable": "Stabil",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "Titik Akhir Kustom Penerjemah Microsoft"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Ulasan",
|
||||
"review_off": "Tembakan cepat",
|
||||
"banners_on": "Spanduk panggung menyala",
|
||||
"banners_off": "Spanduk panggung dilepas",
|
||||
"backend": "Bagian belakang",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "Diaktifkan",
|
||||
"add": "Tambahkan entri",
|
||||
"remove": "Hapus {{term}}",
|
||||
"empty": "Belum ada entri. Tambahkan satu di atas untuk memperbaiki kata yang salah diucapkan.",
|
||||
"empty": "Belum ada entri. Tambahkan satu di bawah untuk memperbaiki kata yang salah diucapkan.",
|
||||
"test_label": "Uji sebuah kalimat",
|
||||
"test_placeholder": "Ketikkan kalimat untuk melihat pratinjau substitusi…",
|
||||
"test_result": "Akan diucapkan sebagai:",
|
||||
"test_nochange": "Tidak ada entri yang cocok — diucapkan seperti tertulis.",
|
||||
"load_error": "Tidak dapat memuat kamus pengucapan.",
|
||||
"save_error": "Tidak dapat menyimpan entri itu.",
|
||||
"lang_label": "Kode bahasa (misalnya en, de) atau biarkan kosong untuk Global"
|
||||
"lang_label": "Kode bahasa (misalnya en, de) atau biarkan kosong untuk Global",
|
||||
"enable_entry": "Aktifkan {{term}}",
|
||||
"lang_placeholder": "en, de… (kosong = Global)",
|
||||
"test_language": "Bahasa pratinjau",
|
||||
"test_global_hint": "Pratinjau hanya dengan entri Global — pilih bahasa untuk menyertakan entri khusus bahasa.",
|
||||
"test_error": "Pratinjau tidak tersedia — backend tidak dapat dihubungi.",
|
||||
"backup_title": "Cadangkan & pulihkan",
|
||||
"backup_hint": "Unduh kamus sebagai file JSON, atau impor entri dari ekspor sebelumnya.",
|
||||
"export": "Ekspor JSON",
|
||||
"import": "Impor JSON",
|
||||
"import_replace_prompt": "Ganti {{count}} entri yang ada dengan file yang diimpor? Pilih Tidak untuk menambahkan entri impor di samping yang ada.",
|
||||
"import_done": "{{count}} entri diimpor.",
|
||||
"import_error": "File tidak dapat diimpor — diharapkan JSON dari ekspor pelafalan.",
|
||||
"export_error": "Kamus tidak dapat diekspor."
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "Buku Audio",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "Impostazioni",
|
||||
"ui_scale": "Scala dell'interfaccia utente",
|
||||
"proxy": "Procura",
|
||||
"proxy_desc": "Proxy HTTP/SOCKS5 per i download (yt-dlp, HuggingFace). Supporta http://, https://, calzini5://. È necessario riavviare se modificato dopo l'avvio del backend.",
|
||||
"proxy_saved": "Set proxy: yt-dlp lo utilizzerà per i download",
|
||||
"proxy_desc": "Proxy HTTP/SOCKS5 per i download (download video, Hugging Face). Supporta http://, https://, socks5://. Si applica subito ai nuovi download.",
|
||||
"proxy_saved": "Proxy impostato — i nuovi download lo useranno",
|
||||
"proxy_save_failed": "Impossibile salvare il proxy",
|
||||
"proxy_cleared": "Proxy cancellato",
|
||||
"proxy_clear": "Chiaro",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "Fornitori di traduzioni",
|
||||
"translation_providers_desc": "Chiavi API per traduttori online, impostate per questa sessione.",
|
||||
"review_mode": "Modalità revisione",
|
||||
"review_mode_on": "Recensione",
|
||||
"review_mode_off": "Fuoco rapido",
|
||||
"review_mode_desc": "Fai una pausa tra le fasi della pipeline in modo da poter rivedere l'output ASR/traduzione.",
|
||||
"header_live_stats": "Mostra le metriche del sistema in tempo reale nell'intestazione",
|
||||
"header_live_stats_desc": "Aggiunge un monitor RAM/CPU/VRAM live alla barra superiore (disattivato per impostazione predefinita).",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "Controlla gli aggiornamenti",
|
||||
"copy_diagnostics": "Copia la diagnostica",
|
||||
"github": "OmniVoice su GitHub",
|
||||
"model_card": "Scheda modello",
|
||||
"commercial_license": "Licenza commerciale",
|
||||
"update_channel": "Canale di aggiornamento",
|
||||
"channel_stable": "Stabile",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "Endpoint personalizzato di Microsoft Translator"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Recensione",
|
||||
"review_off": "Fuoco rapido",
|
||||
"banners_on": "Striscioni sul palco accesi",
|
||||
"banners_off": "Gli striscioni sul palco sono spenti",
|
||||
"backend": "Backend",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "Abilitato",
|
||||
"add": "Aggiungi voce",
|
||||
"remove": "Rimuovi {{term}}",
|
||||
"empty": "Nessuna voce ancora. Aggiungine uno sopra per correggere una parola pronunciata male.",
|
||||
"empty": "Nessuna voce ancora. Aggiungine una qui sotto per correggere una parola pronunciata male.",
|
||||
"test_label": "Metti alla prova una frase",
|
||||
"test_placeholder": "Digita una frase per visualizzare in anteprima la sostituzione...",
|
||||
"test_result": "Verrà parlato come:",
|
||||
"test_nochange": "Nessuna voce corrisponde: pronunciata come scritta.",
|
||||
"load_error": "Impossibile caricare il dizionario della pronuncia.",
|
||||
"save_error": "Impossibile salvare la voce.",
|
||||
"lang_label": "Codice della lingua (ad esempio en, de) o lasciare vuoto per Global"
|
||||
"lang_label": "Codice della lingua (ad esempio en, de) o lasciare vuoto per Global",
|
||||
"enable_entry": "Attiva {{term}}",
|
||||
"lang_placeholder": "en, de… (vuoto = Globale)",
|
||||
"test_language": "Lingua dell'anteprima",
|
||||
"test_global_hint": "Anteprima solo con le voci globali — scegli una lingua per includere le voci specifiche per lingua.",
|
||||
"test_error": "Anteprima non disponibile — impossibile raggiungere il backend.",
|
||||
"backup_title": "Backup e ripristino",
|
||||
"backup_hint": "Scarica il dizionario come file JSON oppure importa voci da un'esportazione precedente.",
|
||||
"export": "Esporta JSON",
|
||||
"import": "Importa JSON",
|
||||
"import_replace_prompt": "Sostituire le {{count}} voci esistenti con il file importato? Scegli No per aggiungere le voci importate a quelle esistenti.",
|
||||
"import_done": "{{count}} voci importate.",
|
||||
"import_error": "Impossibile importare il file — è atteso il JSON di un'esportazione della pronuncia.",
|
||||
"export_error": "Impossibile esportare il dizionario."
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "Audiolibro",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "設定",
|
||||
"ui_scale": "UIスケール",
|
||||
"proxy": "プロキシ",
|
||||
"proxy_desc": "ダウンロード用の HTTP/SOCKS5 プロキシ (yt-dlp、HuggingFace)。 http://、https://、socks5:// をサポートします。バックエンドの起動後に変更した場合は再起動が必要です。",
|
||||
"proxy_saved": "プロキシ セット — yt-dlp はダウンロードにそれを使用します",
|
||||
"proxy_desc": "ダウンロード用の HTTP/SOCKS5 プロキシ(動画ダウンロード、Hugging Face)。http://、https://、socks5:// をサポートします。新しいダウンロードにすぐに適用されます。",
|
||||
"proxy_saved": "プロキシを設定しました — 新しいダウンロードで使用されます",
|
||||
"proxy_save_failed": "プロキシの保存に失敗しました",
|
||||
"proxy_cleared": "プロキシがクリアされました",
|
||||
"proxy_clear": "クリア",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "翻訳プロバイダー",
|
||||
"translation_providers_desc": "このセッション用に設定されたオンライン翻訳者の API キー。",
|
||||
"review_mode": "レビューモード",
|
||||
"review_mode_on": "レビュー",
|
||||
"review_mode_off": "連射",
|
||||
"review_mode_desc": "パイプライン ステージ間で一時停止して、ASR/翻訳出力を確認できるようにします。",
|
||||
"header_live_stats": "ライブシステムメトリクスをヘッダーに表示",
|
||||
"header_live_stats_desc": "ライブ RAM / CPU / VRAM モニターをトップバーに追加します (デフォルトではオフ)。",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "アップデートをチェックする",
|
||||
"copy_diagnostics": "コピー診断",
|
||||
"github": "GitHub 上のオムニボイス",
|
||||
"model_card": "モデルカード",
|
||||
"commercial_license": "商用ライセンス",
|
||||
"update_channel": "アップデートチャンネル",
|
||||
"channel_stable": "安定版",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "Microsoft 翻訳者のカスタム エンドポイント"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "レビュー",
|
||||
"review_off": "連射",
|
||||
"banners_on": "ステージバナーが貼られています",
|
||||
"banners_off": "ステージバナーが消えた",
|
||||
"backend": "バックエンド",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "有効",
|
||||
"add": "エントリを追加",
|
||||
"remove": "{{term}} を削除",
|
||||
"empty": "まだエントリーはありません。発音が間違っている単語を修正するには、上に 1 つ追加します。",
|
||||
"empty": "まだエントリーはありません。発音が間違っている単語を修正するには、下に 1 つ追加します。",
|
||||
"test_label": "文をテストする",
|
||||
"test_placeholder": "文を入力して置換をプレビューします…",
|
||||
"test_result": "次のように話されます。",
|
||||
"test_nochange": "一致するエントリはありません - 書かれたとおりに話されています。",
|
||||
"load_error": "発音辞書を読み込めませんでした。",
|
||||
"save_error": "そのエントリを保存できませんでした。",
|
||||
"lang_label": "言語コード (en、de など)、またはグローバルの場合は空白のままにします"
|
||||
"lang_label": "言語コード (en、de など)、またはグローバルの場合は空白のままにします",
|
||||
"enable_entry": "{{term}} を有効にする",
|
||||
"lang_placeholder": "en, de…(空欄 = グローバル)",
|
||||
"test_language": "プレビューの言語",
|
||||
"test_global_hint": "グローバルなエントリーのみをプレビューしています — 言語別のエントリーを含めるには言語を選択してください。",
|
||||
"test_error": "プレビューを利用できません — バックエンドに接続できませんでした。",
|
||||
"backup_title": "バックアップと復元",
|
||||
"backup_hint": "辞書を JSON ファイルとしてダウンロードするか、以前のエクスポートからエントリーをインポートします。",
|
||||
"export": "JSON をエクスポート",
|
||||
"import": "JSON をインポート",
|
||||
"import_replace_prompt": "既存の {{count}} 件のエントリーをインポートしたファイルで置き換えますか?「いいえ」を選ぶと、既存のエントリーに追加されます。",
|
||||
"import_done": "{{count}} 件のエントリーをインポートしました。",
|
||||
"import_error": "そのファイルをインポートできませんでした — 発音エクスポートの JSON が必要です。",
|
||||
"export_error": "辞書をエクスポートできませんでした。"
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "オーディオブック",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "설정",
|
||||
"ui_scale": "UI 규모",
|
||||
"proxy": "프록시",
|
||||
"proxy_desc": "다운로드용 HTTP/SOCKS5 프록시(yt-dlp, HuggingFace) http://, https://, 양말5://를 지원합니다. 백엔드 시작 후 변경된 경우 다시 시작해야 합니다.",
|
||||
"proxy_saved": "프록시 세트 - yt-dlp가 다운로드에 이를 사용합니다.",
|
||||
"proxy_desc": "다운로드용 HTTP/SOCKS5 프록시(동영상 다운로드, Hugging Face). http://, https://, socks5://를 지원합니다. 새 다운로드에 즉시 적용됩니다.",
|
||||
"proxy_saved": "프록시 설정됨 — 새 다운로드에 사용됩니다",
|
||||
"proxy_save_failed": "프록시를 저장하지 못했습니다.",
|
||||
"proxy_cleared": "프록시가 삭제되었습니다.",
|
||||
"proxy_clear": "지우기",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "번역 제공업체",
|
||||
"translation_providers_desc": "이 세션에 대해 설정된 온라인 번역기용 API 키입니다.",
|
||||
"review_mode": "검토 모드",
|
||||
"review_mode_on": "검토",
|
||||
"review_mode_off": "속사",
|
||||
"review_mode_desc": "ASR/번역 출력을 검토할 수 있도록 파이프라인 단계 사이에 일시 중지합니다.",
|
||||
"header_live_stats": "헤더에 실시간 시스템 측정항목 표시",
|
||||
"header_live_stats_desc": "상단 표시줄에 라이브 RAM/CPU/VRAM 모니터를 추가합니다(기본적으로 꺼져 있음).",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "업데이트 확인",
|
||||
"copy_diagnostics": "진단 복사",
|
||||
"github": "GitHub의 OmniVoice",
|
||||
"model_card": "모델 카드",
|
||||
"commercial_license": "상업용 라이센스",
|
||||
"update_channel": "업데이트 채널",
|
||||
"channel_stable": "안정 버전",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "Microsoft 번역기 사용자 지정 끝점"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "검토",
|
||||
"review_off": "속사",
|
||||
"banners_on": "무대 배너 켜짐",
|
||||
"banners_off": "무대 배너 꺼짐",
|
||||
"backend": "백엔드",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "활성화됨",
|
||||
"add": "항목 추가",
|
||||
"remove": "Remove {{term}}",
|
||||
"empty": "아직 항목이 없습니다. Add one above to fix a mispronounced word.",
|
||||
"empty": "아직 항목이 없습니다. 잘못 발음되는 단어를 고치려면 아래에 하나를 추가하세요.",
|
||||
"test_label": "Test a sentence",
|
||||
"test_placeholder": "Type a sentence to preview the substitution…",
|
||||
"test_result": "Will be spoken as:",
|
||||
"test_nochange": "일치하는 항목이 없습니다. 쓰여진 대로 말합니다.",
|
||||
"load_error": "Couldn't load the pronunciation dictionary.",
|
||||
"save_error": "해당 항목을 저장할 수 없습니다.",
|
||||
"lang_label": "언어 코드(예: en, de) 또는 글로벌의 경우 비워 두세요."
|
||||
"lang_label": "언어 코드(예: en, de) 또는 글로벌의 경우 비워 두세요.",
|
||||
"enable_entry": "{{term}} 활성화",
|
||||
"lang_placeholder": "en, de… (비워 두면 글로벌)",
|
||||
"test_language": "미리보기 언어",
|
||||
"test_global_hint": "글로벌 항목만 미리보기 중 — 언어별 항목을 포함하려면 언어를 선택하세요.",
|
||||
"test_error": "미리보기를 사용할 수 없습니다 — 백엔드에 연결할 수 없습니다.",
|
||||
"backup_title": "백업 및 복원",
|
||||
"backup_hint": "사전을 JSON 파일로 다운로드하거나 이전 내보내기에서 항목을 가져옵니다.",
|
||||
"export": "JSON 내보내기",
|
||||
"import": "JSON 가져오기",
|
||||
"import_replace_prompt": "기존 {{count}}개 항목을 가져온 파일로 바꾸시겠습니까? '아니요'를 선택하면 기존 항목에 추가됩니다.",
|
||||
"import_done": "{{count}}개 항목을 가져왔습니다.",
|
||||
"import_error": "해당 파일을 가져올 수 없습니다 — 발음 내보내기의 JSON이 필요합니다.",
|
||||
"export_error": "사전을 내보낼 수 없습니다."
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "Audiobook",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"title": "Instellingen",
|
||||
"ui_scale": "UI-schaal",
|
||||
"proxy": "Proxy",
|
||||
"proxy_desc": "HTTP/SOCKS5-proxy voor downloads (yt-dlp, HuggingFace). Ondersteunt http://, https://, sokken5://. Opnieuw opstarten vereist indien gewijzigd na het starten van de backend.",
|
||||
"proxy_saved": "Proxyset — yt-dlp zal het gebruiken voor downloads",
|
||||
"proxy_desc": "HTTP/SOCKS5-proxy voor downloads (videodownloads, Hugging Face). Ondersteunt http://, https://, socks5://. Geldt direct voor nieuwe downloads.",
|
||||
"proxy_saved": "Proxy ingesteld — nieuwe downloads gebruiken deze",
|
||||
"proxy_save_failed": "Kan proxy niet opslaan",
|
||||
"proxy_cleared": "Proxy gewist",
|
||||
"proxy_clear": "Duidelijk",
|
||||
@@ -153,6 +153,8 @@
|
||||
"translation_providers": "Vertaalaanbieders",
|
||||
"translation_providers_desc": "API-sleutels voor online vertalers, ingesteld voor deze sessie.",
|
||||
"review_mode": "Review-modus",
|
||||
"review_mode_on": "Beoordeling",
|
||||
"review_mode_off": "Snelvuur",
|
||||
"review_mode_desc": "Pauzeer tussen pijplijnfasen, zodat u de ASR-/vertaaluitvoer kunt bekijken.",
|
||||
"header_live_stats": "Toon live systeemstatistieken in de koptekst",
|
||||
"header_live_stats_desc": "Voegt een live RAM/CPU/VRAM-monitor toe aan de bovenste balk (standaard uitgeschakeld).",
|
||||
@@ -465,7 +467,6 @@
|
||||
"check_updates": "Controleer op updates",
|
||||
"copy_diagnostics": "Diagnostische gegevens kopiëren",
|
||||
"github": "OmniVoice op GitHub",
|
||||
"model_card": "Modelkaart",
|
||||
"commercial_license": "Commerciële licentie",
|
||||
"update_channel": "Updatekanaal",
|
||||
"channel_stable": "Stabiel",
|
||||
@@ -527,8 +528,6 @@
|
||||
"group_microsoft": "Aangepast eindpunt van Microsoft Translator"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Beoordeling",
|
||||
"review_off": "Snelvuur",
|
||||
"banners_on": "Podiumbanners aan",
|
||||
"banners_off": "Podiumbanners uit",
|
||||
"backend": "Achterkant",
|
||||
@@ -1997,14 +1996,27 @@
|
||||
"enabled": "Ingeschakeld",
|
||||
"add": "Vermelding toevoegen",
|
||||
"remove": "Verwijder {{term}}",
|
||||
"empty": "Nog geen vermeldingen. Voeg er hierboven één toe om een verkeerd uitgesproken woord te corrigeren.",
|
||||
"empty": "Nog geen vermeldingen. Voeg er hieronder één toe om een verkeerd uitgesproken woord te corrigeren.",
|
||||
"test_label": "Test een zin",
|
||||
"test_placeholder": "Typ een zin om een voorbeeld van de vervanging te bekijken...",
|
||||
"test_result": "Zal worden uitgesproken als:",
|
||||
"test_nochange": "Er komen geen overeenkomende vermeldingen overeen - gesproken zoals geschreven.",
|
||||
"load_error": "Kan het uitspraakwoordenboek niet laden.",
|
||||
"save_error": "Kan die invoer niet opslaan.",
|
||||
"lang_label": "Taalcode (bijvoorbeeld en, de) of laat leeg voor Globaal"
|
||||
"lang_label": "Taalcode (bijvoorbeeld en, de) of laat leeg voor Globaal",
|
||||
"enable_entry": "{{term}} inschakelen",
|
||||
"lang_placeholder": "en, de… (leeg = Globaal)",
|
||||
"test_language": "Voorbeeldtaal",
|
||||
"test_global_hint": "Voorbeeld met alleen globale vermeldingen — kies een taal om taalspecifieke vermeldingen mee te nemen.",
|
||||
"test_error": "Voorbeeld niet beschikbaar — de backend was niet bereikbaar.",
|
||||
"backup_title": "Back-up & herstel",
|
||||
"backup_hint": "Download het woordenboek als JSON-bestand, of importeer vermeldingen uit een eerdere export.",
|
||||
"export": "JSON exporteren",
|
||||
"import": "JSON importeren",
|
||||
"import_replace_prompt": "De {{count}} bestaande vermeldingen vervangen door het geïmporteerde bestand? Kies Nee om de geïmporteerde vermeldingen ernaast toe te voegen.",
|
||||
"import_done": "{{count}} vermeldingen geïmporteerd.",
|
||||
"import_error": "Kan dat bestand niet importeren — JSON van een uitspraak-export verwacht.",
|
||||
"export_error": "Kan het woordenboek niet exporteren."
|
||||
},
|
||||
"audiobook": {
|
||||
"title": "Audioboek",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user