Compare commits

...
4 Commits
Author SHA1 Message Date
debpalashandClaude Opus 4.8 362dca9ae1 docs(diagnostics): self-diagnosis section in troubleshooting + README pointer
Settings > About self-check / --diagnose / --deep / diagnostic bundle are
now the documented first step before the per-error entries — and the
support team's first ask on every issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:43:03 +05:30
debpalashandClaude Opus 4.8 bbac66ef76 feat(diagnostics): breadcrumbs, deep self-check, report sweep, issue search
Final slice of the bug-tracking work.

- toastErrorWithReport adopted at the high-traffic failure sites: TTS
  generation, dub upload/ingest/transcribe, engine install, engines-matrix
  load, voice profile save/delete/test, batch enqueue/cancel/delete.
  Validation toasts and cancellations stay plain on purpose.
- utils/breadcrumbs.js: local-only ring of the last 20 action names
  (closed-set names only — never content or paths), embedded as a
  'Recent actions' section in the prefilled report. Instrumented: view
  changes, generate, dub pipeline, export, engine switch.
- deep self-check: /system/diagnose?deep=true and --diagnose --deep load
  the active engine and synthesize a short utterance (num_step=4) —
  catches 'installed but broken'. 180s time-box, skips during model load,
  scrubbed failure detail. Verified live: cold-loaded omnivoice and
  produced 2.2s of audio in 43.9s on CUDA.
- 'Search similar issues' action on the ErrorBoundary: scrubbed,
  noise-stripped GitHub issue search URL — dedupe before filing.
- bug_report.md template now points at the diagnostic bundle and the
  --diagnose CLI so manual reports arrive with the same evidence.

Tests: pytest 107 passed (4 new deep-check tests, CJK gate green);
vitest 218 passed (breadcrumbs + issue-search suites); vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:43:03 +05:30
debpalashandClaude Opus 4.8 3d74611be8 feat(diagnostics): error journal, diagnostic bundle, crash notice, global handlers
Second slice of the bug-tracking work — still zero outbound paths beyond
the user's own browser/file manager.

- core/error_journal.py: deduped ring of recent unhandled backend errors
  (fingerprint counts, error_class triage: GPU_OOM, HF_AUTH_FAILED,
  PYANNOTE_LICENSE_REQUIRED, DISK_FULL, FFMPEG_MISSING, NETWORK_ERROR),
  scrubbed, JSONL-persisted so the error that killed the last run survives
  restart. Wired into the global exception handler; 500 bodies now carry
  error_class; GET /system/errors/recent.
- core/diagnostic_bundle.py + POST /system/diagnostic-bundle + Settings >
  About 'Save diagnostic bundle': zip of self-check report, error journal,
  scrubbed log tails — drag onto a GitHub issue; bypasses the ~8k
  prefill-URL ceiling.
- crash-on-next-launch: /system/notifications flags a crash logged before
  this session started (size vs acked-size in prefs, mtime vs process
  start); POST /system/crash/ack; LogsFooter acks on action click.
- utils/globalErrorHandlers.js: uncaught errors + unhandled rejections get
  a throttled, noise-filtered 'Report this bug' toast.
- sidecar log parity fix: _tauri_log_candidates() now lists the Rust
  sidecar's backend.log/backend_err.log on Linux (XDG state dir) and
  Windows (LOCALAPPDATA) — sidecar crashes were only visible on macOS.

Tests: +19 pytest (journal, bundle); suite at 102 passed. Vitest 124
passed; vite build green. Live-verified: journal recorded and classified
a real HF 401 from the test run (HF_AUTH_FAILED, paths scrubbed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:43:03 +05:30
debpalashandClaude Opus 4.8 ee08f56e1b feat(diagnostics): local self-check + scrubbed bug-report pipeline
Closes the gap between 'something broke' and 'a useful GitHub issue
exists' — entirely within the local-first constraint: the only outbound
path remains the user's own browser opening a prefilled issues/new URL.

Backend:
- core/scrub.py: privacy scrubber for anything leaving the machine —
  env-var secret values (*TOKEN*|*KEY*|*SECRET*|*PASSWORD*), credential
  shapes (hf_/ghp_/github_pat_/sk-), home dirs on all three OSes
- core/diagnose.py: 9-check self-check (device+GPU, ffmpeg, HF token,
  disk, data-dir writability, RAM, engine registry, hub reachability),
  pre-scrubbed, ASCII-safe output
- GET /system/diagnose + 'python main.py --diagnose' (exit 0/1)
- /system/info: hardware inventory (os_version, cpu_model, cpu_count,
  ram_total_gb, gpu_name, vram_total_gb, disk_free_gb), cached statics

Frontend:
- utils/bugReport.js: single source for the prefilled-URL builder —
  scrubText twin, hardware context capture, scrubbed error+stack embed,
  URL-length cap; ReportBugButton refactored onto it
- ErrorBoundary 'Report this bug' action with the error attached
- utils/errorToast.jsx toastErrorWithReport(); wired into export toasts
- Settings > About 'Run self-check' with per-check status badges

Tests: 27 pytest (scrub, diagnose) + 15 vitest (bugReport); existing
suites green; verified live (--diagnose, TestClient, vite build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:43:03 +05:30
33 changed files with 2031 additions and 113 deletions
+7
View File
@@ -25,6 +25,12 @@ What you expected to happen.
If applicable, add screenshots or paste relevant logs from **Settings → Logs**. If applicable, add screenshots or paste relevant logs from **Settings → Logs**.
> **Tip:** **Settings → About → "Save diagnostic bundle"** produces a zip
> (self-check report, recent errors, scrubbed log tails) you can drag onto
> this issue — it answers most environment questions below automatically.
> Headless installs: `python backend/main.py --diagnose` prints the same
> self-check (`--deep` also test-loads the active engine).
## Environment ## Environment
- **OS:** [e.g. macOS 15.2, Windows 11, Ubuntu 24.04] - **OS:** [e.g. macOS 15.2, Windows 11, Ubuntu 24.04]
@@ -32,6 +38,7 @@ If applicable, add screenshots or paste relevant logs from **Settings → Logs**
- **Version:** [e.g. v0.2.7 — check Settings → About] - **Version:** [e.g. v0.2.7 — check Settings → About]
- **GPU:** [e.g. NVIDIA RTX 4090 / Apple M3 Pro / CPU only] - **GPU:** [e.g. NVIDIA RTX 4090 / Apple M3 Pro / CPU only]
- **RAM:** [e.g. 16 GB] - **RAM:** [e.g. 16 GB]
- **Active TTS engine:** [e.g. omnivoice — check Settings → Engines]
## Additional context ## Additional context
+7 -3
View File
@@ -125,9 +125,13 @@ Per-OS install guides — pick yours and follow it end-to-end:
- **Linux** — [docs/install/linux.md](docs/install/linux.md) - **Linux** — [docs/install/linux.md](docs/install/linux.md)
- **Docker** — [docs/install/docker.md](docs/install/docker.md) - **Docker** — [docs/install/docker.md](docs/install/docker.md)
Stuck? See [docs/install/troubleshooting.md](docs/install/troubleshooting.md) Stuck? Run the built-in self-check first — **Settings → About → "Run
for the top 10 install errors. The in-app error UI deeplinks to those entries self-check"** in the app, or `uv run python backend/main.py --diagnose` from
when something breaks at runtime. a checkout (`--deep` also test-loads the active engine). Then see
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) for the
top 10 install errors. The in-app error UI deeplinks to those entries when
something breaks at runtime, and **Settings → About → "Save diagnostic
bundle"** packages scrubbed logs + the self-check report for bug reports.
For Hugging Face token setup, see For Hugging Face token setup, see
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md). For [docs/setup/huggingface-token.md](docs/setup/huggingface-token.md). For
+187 -5
View File
@@ -1,6 +1,7 @@
import os import os
import sys import sys
import platform import platform
import time
import uuid import uuid
import psutil import psutil
import asyncio import asyncio
@@ -40,6 +41,57 @@ _is_cuda = torch.cuda.is_available()
psutil.cpu_percent(interval=None) psutil.cpu_percent(interval=None)
def _detect_cpu_model() -> str:
"""Human-readable CPU model. platform.processor() is empty on most
Linux distros, so read /proc/cpuinfo there; sysctl on macOS."""
try:
if sys.platform.startswith("linux"):
with open("/proc/cpuinfo") as f:
for line in f:
if line.lower().startswith("model name"):
return line.split(":", 1)[1].strip()
if sys.platform == "darwin":
import subprocess
return subprocess.check_output(
["sysctl", "-n", "machdep.cpu.brand_string"], text=True, timeout=5
).strip()
return platform.processor() or ""
except Exception:
return platform.processor() or ""
def _detect_gpu() -> tuple[str, float]:
"""(gpu_name, vram_total_gb) — static for the process lifetime.
MPS has unified memory, so there's no separate VRAM figure to report;
the name alone tells a bug-report reader what hardware this is.
"""
try:
if _is_cuda:
props = torch.cuda.get_device_properties(0)
return torch.cuda.get_device_name(0), round(props.total_memory / (1024 ** 3), 1)
if _is_mac:
return "Apple Silicon (MPS)", 0.0
except Exception:
pass
return "", 0.0
# Static hardware facts, captured once — /system/info is hit on every
# Settings page load and must stay cheap.
_CPU_MODEL = _detect_cpu_model()
_GPU_NAME, _VRAM_TOTAL_GB = _detect_gpu()
_RAM_TOTAL_GB = round(psutil.virtual_memory().total / (1024 ** 3), 1)
_OS_VERSION = platform.platform()
def _disk_free_gb() -> float:
try:
return round(shutil.disk_usage(DATA_DIR).free / (1024 ** 3), 1)
except Exception:
return 0.0
def _ui_port() -> int: def _ui_port() -> int:
"""The Vite UI dev-server port, single-sourced from OMNIVOICE_UI_PORT. """The Vite UI dev-server port, single-sourced from OMNIVOICE_UI_PORT.
@@ -183,6 +235,13 @@ def system_info():
"python": sys.version.split()[0], "python": sys.version.split()[0],
"platform": sys.platform, "platform": sys.platform,
"arch": platform.machine(), "arch": platform.machine(),
"os_version": _OS_VERSION,
"cpu_model": _CPU_MODEL,
"cpu_count": psutil.cpu_count(logical=True) or 0,
"ram_total_gb": _RAM_TOTAL_GB,
"gpu_name": _GPU_NAME,
"vram_total_gb": _VRAM_TOTAL_GB,
"disk_free_gb": _disk_free_gb(),
"ffmpeg_ok": bool(_ffmpeg), "ffmpeg_ok": bool(_ffmpeg),
"ffmpeg_path": _ffmpeg or "", "ffmpeg_path": _ffmpeg or "",
"proxy_url": os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or "", "proxy_url": os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or "",
@@ -210,6 +269,13 @@ def system_info():
"python": sys.version.split()[0], "python": sys.version.split()[0],
"platform": sys.platform, "platform": sys.platform,
"arch": platform.machine(), "arch": platform.machine(),
"os_version": _OS_VERSION,
"cpu_model": _CPU_MODEL,
"cpu_count": psutil.cpu_count(logical=True) or 0,
"ram_total_gb": _RAM_TOTAL_GB,
"gpu_name": _GPU_NAME,
"vram_total_gb": _VRAM_TOTAL_GB,
"disk_free_gb": _disk_free_gb(),
"proxy_url": "", "proxy_url": "",
"share_enabled": network_share.get_state().enabled, "share_enabled": network_share.get_state().enabled,
"share_port": network_share.get_state().share_port, "share_port": network_share.get_state().share_port,
@@ -232,11 +298,19 @@ def _tail_file(path: str, tail: int):
def _tauri_log_candidates(): def _tauri_log_candidates():
"""Likely paths for Tauri-side logs, most useful first. """Likely paths for Tauri-side logs, most useful first.
`tauri-plugin-log` writes to `~/Library/Logs/<bundle_id>/<file_name>.log` Two distinct producers, both per-platform:
by default on macOS. Our bundle id is `com.debpalash.omnivoice-studio`
(see frontend/src-tauri/tauri.conf.json). lib.rs also redirects the - `tauri-plugin-log` writes `tauri.log` to the app log dir
spawned backend's stdout/stderr to `~/Library/Logs/OmniVoice/backend.log` (`~/Library/Logs/<bundle_id>` on macOS, `$XDG_DATA_HOME/<bundle_id>/logs`
which is where `print()` calls and uvicorn startup banners land. on Linux, `%LOCALAPPDATA%\\<bundle_id>\\logs` on Windows). Bundle id is
`com.debpalash.omnivoice-studio` (frontend/src-tauri/tauri.conf.json).
- backend.rs::backend_log_path() redirects the spawned backend's
stdout/stderr to `backend.log` / `backend_err.log` under
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/OmniVoice` falling
back to `~/.local/state/OmniVoice` (Linux), and
`%LOCALAPPDATA%\\OmniVoice\\Logs` (Windows). This is where uvicorn
startup banners and hard-crash tracebacks land — keep all three OS
shapes listed or sidecar crashes become invisible off-macOS.
""" """
home = os.path.expanduser("~") home = os.path.expanduser("~")
bid = "com.debpalash.omnivoice-studio" bid = "com.debpalash.omnivoice-studio"
@@ -248,14 +322,21 @@ def _tauri_log_candidates():
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"), os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
] ]
if sys.platform.startswith("linux"): if sys.platform.startswith("linux"):
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
return [ return [
os.path.join(home, ".local/share", bid, "logs", "tauri.log"), os.path.join(home, ".local/share", bid, "logs", "tauri.log"),
os.path.join(home, ".config", bid, "logs", "tauri.log"), os.path.join(home, ".config", bid, "logs", "tauri.log"),
os.path.join(state_dir, "OmniVoice", "backend.log"),
os.path.join(state_dir, "OmniVoice", "backend_err.log"),
] ]
if sys.platform.startswith("win"): if sys.platform.startswith("win"):
appdata = os.environ.get("APPDATA", home) appdata = os.environ.get("APPDATA", home)
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
return [ return [
os.path.join(localappdata, bid, "logs", "tauri.log"),
os.path.join(appdata, bid, "logs", "tauri.log"), os.path.join(appdata, bid, "logs", "tauri.log"),
os.path.join(localappdata, "OmniVoice", "Logs", "backend.log"),
os.path.join(localappdata, "OmniVoice", "Logs", "backend_err.log"),
] ]
return [] return []
@@ -569,9 +650,57 @@ def system_notifications():
"action": None, "action": None,
}) })
# 5. A previous session logged a crash the user never saw.
# crash_log grew past the last acknowledged size AND predates this
# process — i.e. it happened last run, not just now (errors from the
# current session already surfaced as toasts).
try:
if _crashed_last_session():
notes.append({
"id": "crash-last-session",
"level": "error",
"title": "Last session ended with an error",
"message": (
"A crash was logged before this session started. "
"Review the backend log and consider filing a report."
),
"action": {
"label": "View logs",
"type": "navigate",
"target": "settings",
},
})
except Exception:
pass
return {"notifications": notes, "count": len(notes)} return {"notifications": notes, "count": len(notes)}
# Process start time — anchors "did the crash happen before this run?".
_PROCESS_START_TS = time.time()
def _crashed_last_session() -> bool:
from core.prefs import get as prefs_get
if not os.path.exists(CRASH_LOG_PATH):
return False
size = os.path.getsize(CRASH_LOG_PATH)
acked = int(prefs_get("crash_log_acked_size", 0) or 0)
if size <= acked:
return False
return os.path.getmtime(CRASH_LOG_PATH) < _PROCESS_START_TS
@router.post("/system/crash/ack")
async def ack_crash():
"""Mark the current crash log as seen — dismisses the
'crash-last-session' notification until the log grows again."""
size = os.path.getsize(CRASH_LOG_PATH) if os.path.exists(CRASH_LOG_PATH) else 0
prefs_set("crash_log_acked_size", size)
return {"acked_size": size}
# ── Environment variable setter ─────────────────────────────────────────── # ── Environment variable setter ───────────────────────────────────────────
@@ -789,6 +918,59 @@ def hf_token_state():
} }
# ── Error journal ─────────────────────────────────────────────────────────
@router.get("/system/errors/recent")
def recent_errors(limit: int = Query(20, ge=1, le=50)):
"""Recent unhandled backend errors, newest first — structured, deduped
(count per fingerprint), classified (error_class), pre-scrubbed. The
bug-report pipeline reads this to auto-attach the most recent backend
failure; Settings → Logs can render it as a triage view.
"""
from core import error_journal
errors = error_journal.recent(limit)
return {"errors": errors, "count": len(errors)}
# ── Diagnostic bundle ─────────────────────────────────────────────────────
@router.post("/system/diagnostic-bundle")
async def diagnostic_bundle(network: bool = Query(False, description="Include the hub reachability probe")):
"""Build the drag-onto-a-GitHub-issue zip (core.diagnostic_bundle):
self-check report, recent error journal, scrubbed log tails. Returns the
local path so the UI can reveal it in the file manager. The path itself
is NOT scrubbed — this response never leaves the machine; the zip's
*contents* are scrubbed because the zip does.
"""
from core.diagnostic_bundle import build_bundle
path = await asyncio.to_thread(build_bundle, network)
return {"path": path, "filename": os.path.basename(path)}
# ── Self-check diagnostics ────────────────────────────────────────────────
@router.get("/system/diagnose")
async def system_diagnose(
network: bool = Query(True, description="Include the HuggingFace hub reachability probe"),
deep: bool = Query(False, description="Also load the active engine and synthesize a short utterance (may cold-load the model — minutes on first run)"),
):
"""Run the self-check suite (core.diagnose) and return the structured report.
The hub probe can block up to ~5s (and ``deep=true`` far longer), so the
whole run goes through a threadpool; pass ``network=false`` for an
instant offline report. Output is pre-scrubbed (core.scrub) — safe to
paste into a GitHub issue.
"""
from core.diagnose import run_diagnostics
return await asyncio.to_thread(run_diagnostics, network, deep)
# ── Phase 1 Wave 3 — macOS Gatekeeper quarantine probe (#54) ──────────── # ── Phase 1 Wave 3 — macOS Gatekeeper quarantine probe (#54) ────────────
+7
View File
@@ -38,6 +38,13 @@ class SystemInfoResponse(BaseModel):
python: str = "" python: str = ""
platform: str = "" platform: str = ""
arch: str = "" arch: str = ""
os_version: str = ""
cpu_model: str = ""
cpu_count: int = 0
ram_total_gb: float = 0.0
gpu_name: str = ""
vram_total_gb: float = 0.0
disk_free_gb: float = 0.0
error: str | None = None error: str | None = None
ffmpeg_ok: bool = False ffmpeg_ok: bool = False
ffmpeg_path: str = "" ffmpeg_path: str = ""
+342
View File
@@ -0,0 +1,342 @@
"""Self-check diagnostics — answers "why doesn't it work on my machine?"
One pass over everything a working install needs: Python, compute device,
ffmpeg, HF token, disk, data-dir permissions, RAM, TTS engines, and (when
requested) network reachability of the HuggingFace hub. Surfaced two ways:
- ``GET /system/diagnose`` (Settings > About → "Run self-check")
- ``python main.py --diagnose`` for headless installs / issue triage
Every ``detail``/``hint`` string is passed through ``core.scrub`` before it
leaves this module, so the report is safe to paste straight into a GitHub
issue — that's its whole purpose.
Check shape:
{"id": str, "label": str, "status": "ok"|"warn"|"fail",
"detail": str, "hint": Optional[str]}
``fail`` = the app cannot do its job (no disk, unwritable data dir).
``warn`` = degraded but usable (CPU-only, no HF token, hub unreachable).
"""
from __future__ import annotations
import os
import platform
import shutil
import sys
from core.config import DATA_DIR
from core.scrub import scrub_text
from core.version import APP_VERSION
OK = "ok"
WARN = "warn"
FAIL = "fail"
# Below this much free disk the model cache can't even hold one engine.
_DISK_FAIL_GB = 2
_DISK_WARN_GB = 10
_RAM_WARN_GB = 8
_HUB_URL = "https://huggingface.co"
_HUB_TIMEOUT_S = 5
def _check(check_id: str, label: str, status: str, detail: str, hint: str | None = None) -> dict:
return {
"id": check_id,
"label": label,
"status": status,
"detail": scrub_text(detail),
"hint": scrub_text(hint) if hint else None,
}
def _check_python() -> dict:
return _check(
"python", "Python runtime", OK,
f"{sys.version.split()[0]} on {platform.platform()}",
)
def _check_device() -> dict:
try:
from services.model_manager import get_best_device
device = get_best_device()
except Exception as e:
return _check(
"device", "Compute device", FAIL,
f"device detection failed: {e}",
"Reinstall may be needed - torch could not initialize.",
)
gpu_name = ""
try:
import torch
if torch.cuda.is_available():
gpu_name = torch.cuda.get_device_name(0)
except Exception:
pass
if device == "cpu":
return _check(
"device", "Compute device", WARN,
"cpu (no GPU acceleration detected)",
"Generation will be slow. If this machine has a GPU, check CUDA/ROCm drivers (Linux/Windows) or that you're on Apple Silicon (macOS).",
)
detail = f"{device} ({gpu_name})" if gpu_name else device
return _check("device", "Compute device", OK, detail)
def _check_ffmpeg() -> dict:
try:
from services.ffmpeg_utils import find_ffmpeg
path = find_ffmpeg()
except Exception:
path = None
if path:
return _check("ffmpeg", "ffmpeg", OK, str(path))
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.",
)
def _check_hf_token() -> dict:
# Presence only — the resolver never hands us the raw token and we
# wouldn't print it anyway.
try:
from services import token_resolver
present = token_resolver.resolve() is not None
except Exception:
present = False
if present:
return _check("hf_token", "HuggingFace token", OK, "configured")
return _check(
"hf_token", "HuggingFace token", WARN,
"not set",
"Downloads may be rate-limited and speaker diarization won't work. Set one in Settings > Credentials.",
)
def _check_disk() -> dict:
try:
usage = shutil.disk_usage(DATA_DIR)
except Exception as e:
return _check("disk", "Disk space", WARN, f"could not stat {DATA_DIR}: {e}")
free_gb = usage.free / (1024 ** 3)
detail = f"{free_gb:.1f} GB free at {DATA_DIR}"
if free_gb < _DISK_FAIL_GB:
return _check(
"disk", "Disk space", FAIL, detail,
"Model downloads need several GB. Free up space or move OMNIVOICE_DATA_DIR to a larger volume.",
)
if free_gb < _DISK_WARN_GB:
return _check(
"disk", "Disk space", WARN, detail,
"Engine model downloads can be 1-4 GB each; you may run out mid-download.",
)
return _check("disk", "Disk space", OK, detail)
def _check_data_dir() -> dict:
probe = os.path.join(DATA_DIR, ".diagnose_write_probe")
try:
with open(probe, "w") as f:
f.write("ok")
os.remove(probe)
return _check("data_dir", "Data directory", OK, f"writable: {DATA_DIR}")
except Exception as e:
return _check(
"data_dir", "Data directory", FAIL,
f"not writable: {DATA_DIR} ({e})",
"Voices, projects, and logs all live here. Fix permissions or point OMNIVOICE_DATA_DIR somewhere writable.",
)
def _check_ram() -> dict:
try:
import psutil
total_gb = psutil.virtual_memory().total / (1024 ** 3)
except Exception as e:
return _check("ram", "System memory", WARN, f"could not read: {e}")
detail = f"{total_gb:.1f} GB total"
if total_gb < _RAM_WARN_GB:
return _check(
"ram", "System memory", WARN, detail,
"Large engines may swap or OOM below 8 GB. Prefer lighter engines and close other apps while generating.",
)
return _check("ram", "System memory", OK, detail)
def _check_engines() -> dict:
try:
from services.tts_backend import list_backends, active_backend_id
backends = list_backends()
active = active_backend_id()
except Exception as e:
return _check("engines", "TTS engines", WARN, f"could not enumerate: {e}")
available = [b["id"] for b in backends if b.get("available")]
detail = f"active: {active}; available: {', '.join(available) or 'none'}"
active_row = next((b for b in backends if b.get("id") == active), None)
if active_row is not None and not active_row.get("available"):
reason = active_row.get("reason") or "unavailable"
return _check(
"engines", "TTS engines", FAIL,
f"{detail} - active engine '{active}' is unavailable: {reason}",
active_row.get("install_hint") or "Pick a different engine in Settings > Engines.",
)
if not available:
return _check(
"engines", "TTS engines", FAIL, detail,
"No usable TTS engine. Install one from Settings > Engines.",
)
return _check("engines", "TTS engines", OK, detail)
_DEEP_TIMEOUT_S = 180
def _check_deep_synthesis() -> dict:
"""Actually load the active engine and synthesize a short utterance.
Catches "installed but broken" — the most common issue category — which
the presence checks above can't see. Opt-in only (?deep=true / --deep):
it may cold-load the model (minutes + a multi-GB download on a fresh
install), so it must never run on a casual Settings-page self-check.
"""
try:
from services.model_manager import get_model_status
if get_model_status().get("status") == "loading":
return _check(
"deep_synth", "Deep synthesis", WARN,
"skipped - a model load is already in progress",
"Re-run once the current load finishes.",
)
except Exception:
pass
import concurrent.futures
import time as _time
def _synth():
import services.model_manager as mm
from services.tts_backend import get_active_tts_backend, active_backend_id
backend = get_active_tts_backend(model=mm.model)
wav = backend.generate("Diagnostics check, one two three.", num_step=4)
return active_backend_id(), int(wav.shape[-1]) / max(1, backend.sample_rate)
t0 = _time.perf_counter()
ex = concurrent.futures.ThreadPoolExecutor(max_workers=1)
try:
engine_id, audio_s = ex.submit(_synth).result(timeout=_DEEP_TIMEOUT_S)
except concurrent.futures.TimeoutError:
return _check(
"deep_synth", "Deep synthesis", FAIL,
f"timed out after {_DEEP_TIMEOUT_S}s - engine load or synthesis hung",
"If this is a first run, the model may still be downloading - retry later. Otherwise check the backend log for where it stalled.",
)
except Exception as e:
return _check(
"deep_synth", "Deep synthesis", FAIL,
f"active engine failed: {type(e).__name__}: {e}",
"The engine is installed but not producing audio. The error above is the lead; Settings > Logs has the full trace.",
)
finally:
# Never block the report on a hung worker; the thread is left to
# finish (or hang) on its own — the timeout verdict already shipped.
ex.shutdown(wait=False)
elapsed = _time.perf_counter() - t0
if audio_s <= 0:
return _check(
"deep_synth", "Deep synthesis", FAIL,
f"engine '{engine_id}' returned empty audio in {elapsed:.1f}s",
"Synthesis ran but produced no samples - engine output is broken.",
)
return _check(
"deep_synth", "Deep synthesis", OK,
f"engine '{engine_id}' produced {audio_s:.1f}s of audio in {elapsed:.1f}s",
)
def _check_network() -> dict:
# Any HTTP response — even a 4xx — proves the hub is reachable; that's
# all model downloads need to get started. urllib honors HTTP(S)_PROXY.
import urllib.request
import urllib.error
req = urllib.request.Request(_HUB_URL, method="HEAD")
try:
with urllib.request.urlopen(req, timeout=_HUB_TIMEOUT_S):
pass
return _check("network", "HuggingFace hub", OK, f"{_HUB_URL} reachable")
except urllib.error.HTTPError:
return _check("network", "HuggingFace hub", OK, f"{_HUB_URL} reachable")
except Exception as e:
return _check(
"network", "HuggingFace hub", WARN,
f"{_HUB_URL} unreachable: {e}",
"Model downloads will fail until this resolves. Behind a restricted network, set a proxy in Settings > General or configure a mirror via HF_ENDPOINT.",
)
def run_diagnostics(include_network: bool = True, deep: bool = False) -> dict:
"""Run every check and return the structured report.
``include_network=False`` skips the hub probe — used by tests and by
callers that need the report to come back instantly offline.
``deep=True`` additionally loads the active engine and synthesizes a
short utterance (may take minutes on a cold install — opt-in only).
"""
checks = [
_check_python(),
_check_device(),
_check_ffmpeg(),
_check_hf_token(),
_check_disk(),
_check_data_dir(),
_check_ram(),
_check_engines(),
]
if include_network:
checks.append(_check_network())
if deep:
checks.append(_check_deep_synthesis())
counts = {OK: 0, WARN: 0, FAIL: 0}
for c in checks:
counts[c["status"]] += 1
return {
"app_version": APP_VERSION,
"platform": scrub_text(platform.platform()),
"checks": checks,
"summary": {
"ok": counts[FAIL] == 0,
"passed": counts[OK],
"warnings": counts[WARN],
"failures": counts[FAIL],
},
}
def format_text(report: dict) -> str:
"""Human-readable rendering for `--diagnose` / pasting into an issue.
ASCII-only on purpose — Windows consoles with legacy code pages must
not choke on the output.
"""
tag = {OK: "[ OK ]", WARN: "[WARN]", FAIL: "[FAIL]"}
lines = [
f"OmniVoice Studio self-check - v{report['app_version']} on {report['platform']}",
"",
]
for c in report["checks"]:
lines.append(f"{tag[c['status']]} {c['label']}: {c['detail']}")
if c.get("hint"):
lines.append(f" hint: {c['hint']}")
s = report["summary"]
lines.append("")
lines.append(
f"{s['passed']} ok, {s['warnings']} warning(s), {s['failures']} failure(s) - "
+ ("looks healthy" if s["ok"] else "needs attention")
)
return "\n".join(lines)
+85
View File
@@ -0,0 +1,85 @@
"""Diagnostic bundle — everything a maintainer needs, in one drag-and-drop.
The prefilled GitHub Issues URL caps out around 8k characters, so logs can
never ride along with a report. This module zips the full picture instead:
omnivoice-diagnostics-<timestamp>.zip
├── meta.json app version, platform, python, generated-at
├── self_check.txt human-readable diagnose report
├── self_check.json same, structured
├── errors.json recent error journal (deduped, classified)
└── logs/
├── omnivoice.log.txt last 500 lines, scrubbed
└── crash_log.txt last 200 lines, scrubbed
Settings → About → "Save diagnostic bundle" builds it and reveals the file;
the user drags it onto their GitHub issue. Every text member is passed
through core.scrub — the bundle is built TO leave the machine, so it must
be safe by construction. The zip is written to OUTPUTS_DIR (user-visible,
already revealed-in-folder elsewhere in the app).
"""
from __future__ import annotations
import json
import os
import platform
import sys
import time
import zipfile
from core.config import OUTPUTS_DIR, LOG_PATH, CRASH_LOG_PATH
from core.scrub import scrub_text
from core.version import APP_VERSION
_LOG_TAIL_LINES = 500
_CRASH_TAIL_LINES = 200
def _scrubbed_tail(path: str, max_lines: int) -> str:
"""Last `max_lines` of `path`, scrubbed. Missing/unreadable file → a
one-line note instead of a hard failure (the bundle must always build)."""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except FileNotFoundError:
return f"(no file at {scrub_text(path)})\n"
except Exception as e:
return f"(could not read {scrub_text(path)}: {scrub_text(str(e))})\n"
return scrub_text("".join(lines[-max_lines:]))
def build_bundle(include_network: bool = False) -> str:
"""Build the zip and return its absolute path.
``include_network=False`` by default: the bundle is usually requested
exactly when something is wrong, and a hung hub probe shouldn't add 5s
to "save the evidence".
"""
from core.diagnose import run_diagnostics, format_text
from core import error_journal
report = run_diagnostics(include_network=include_network)
meta = {
"app_version": APP_VERSION,
"platform": scrub_text(platform.platform()),
"python": sys.version.split()[0],
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
stamp = time.strftime("%Y%m%d-%H%M%S")
os.makedirs(OUTPUTS_DIR, exist_ok=True)
out_path = os.path.join(OUTPUTS_DIR, f"omnivoice-diagnostics-{stamp}.zip")
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("meta.json", json.dumps(meta, indent=2, ensure_ascii=False))
zf.writestr("self_check.txt", format_text(report))
zf.writestr("self_check.json", json.dumps(report, indent=2, ensure_ascii=False))
zf.writestr(
"errors.json",
json.dumps(error_journal.recent(50), indent=2, ensure_ascii=False),
)
zf.writestr("logs/omnivoice.log.txt", _scrubbed_tail(LOG_PATH, _LOG_TAIL_LINES))
zf.writestr("logs/crash_log.txt", _scrubbed_tail(CRASH_LOG_PATH, _CRASH_TAIL_LINES))
return out_path
+206
View File
@@ -0,0 +1,206 @@
"""Ring journal of recent backend errors — the "what just broke" store.
The global exception handler (main.py) records every unhandled exception
here. Unlike crash_log.txt (append-only plain text for humans), the journal
is structured and deduplicated, so the UI and the bug-report pipeline can
answer:
- what was the most recent backend error? (auto-attach to a report)
- is it the same error repeating? (count by fingerprint, "x14 since start")
- what KIND of failure is it? (error_class — GPU_OOM, HF_AUTH_FAILED, …)
Everything stored is pre-scrubbed (core.scrub) because journal entries feed
the diagnostic bundle and prefilled GitHub issues. In-memory ring of
``_MAX_ENTRIES`` fingerprints, mirrored to ``DATA_DIR/error_journal.jsonl``
(rewritten on each record — entry count is small, atomicity beats append
here) so the journal survives restarts and the crash it just recorded.
``error_class`` values: the install-time classes reuse the locked taxonomy
keys from core.error_docs_map (HF_AUTH_FAILED, PYANNOTE_LICENSE_REQUIRED) so
docs deeplinks keep working; runtime classes (GPU_OOM, DISK_FULL,
NETWORK_ERROR, FFMPEG_MISSING) are journal-local and fall back to
DEFAULT_DOCS in lookup(). Don't add them to ERROR_DOCS without following
the 4-step mirror contract documented there.
"""
from __future__ import annotations
import json
import os
import threading
import time
from collections import OrderedDict
from core.config import DATA_DIR
from core.scrub import scrub_text
JOURNAL_PATH = os.path.join(DATA_DIR, "error_journal.jsonl")
_MAX_ENTRIES = 50
_MAX_TRACE_CHARS = 4000
_lock = threading.Lock()
# fingerprint -> entry, oldest first (move_to_end on repeat).
_entries: "OrderedDict[str, dict]" = OrderedDict()
# Ordered: first match wins, most specific patterns up top.
_CLASS_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("GPU_OOM", (
"cuda out of memory",
"mps backend out of memory",
"hip out of memory",
"out of memory on device",
)),
("PYANNOTE_LICENSE_REQUIRED", (
"pyannote", # only meaningful combined with an auth marker — see classify()
)),
("HF_AUTH_FAILED", (
"401 client error",
"403 client error",
"gatedrepoerror",
"repository not found",
"invalid user token",
"huggingface_hub.errors",
)),
("DISK_FULL", (
"no space left on device",
"errno 28",
"disk quota exceeded",
)),
("FFMPEG_MISSING", (
"ffmpeg not found",
"ffmpeg is not installed",
"no such file or directory: 'ffmpeg'",
)),
("NETWORK_ERROR", (
"connection refused",
"connection reset",
"connection aborted",
"timed out",
"timeout",
"name or service not known",
"temporary failure in name resolution",
"ssl",
"proxyerror",
)),
)
_AUTH_MARKERS = ("401", "403", "gated", "access", "token")
def classify_exception(exc: BaseException, trace: str = "") -> str:
"""Best-effort classification of an exception into a stable class key.
Pattern-matching on message text is inherently fuzzy — the goal is
triage ("which docs page / which hint"), not perfection. UNKNOWN is an
acceptable answer.
"""
blob = f"{type(exc).__name__}: {exc}\n{trace}".lower()
for cls, needles in _CLASS_RULES:
if cls == "PYANNOTE_LICENSE_REQUIRED":
# pyannote in the trace alone is too broad (any diarization bug
# would match); require an auth/gating marker alongside it.
if "pyannote" in blob and any(m in blob for m in _AUTH_MARKERS):
return cls
continue
if any(n in blob for n in needles):
return cls
return "UNKNOWN"
def _fingerprint(error_class: str, exc: BaseException) -> str:
import hashlib
raw = f"{error_class}|{type(exc).__name__}|{scrub_text(str(exc))[:200]}"
return hashlib.sha1(raw.encode("utf-8", "replace")).hexdigest()[:16]
def _persist_locked() -> None:
"""Rewrite the JSONL mirror from the in-memory ring. Caller holds _lock.
Never raises — losing persistence must not break the exception handler."""
try:
tmp = JOURNAL_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for entry in _entries.values():
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
os.replace(tmp, JOURNAL_PATH)
except Exception:
pass
def _hydrate() -> None:
"""Load persisted entries at import so 'recent errors' survives restarts
(and shows the error that killed the previous run)."""
try:
with open(JOURNAL_PATH, encoding="utf-8") as f:
for line in f:
try:
entry = json.loads(line)
fp = entry.get("fingerprint")
if fp:
_entries[fp] = entry
except Exception:
continue
while len(_entries) > _MAX_ENTRIES:
_entries.popitem(last=False)
except FileNotFoundError:
pass
except Exception:
pass
_hydrate()
def record(exc: BaseException, route: str = "", trace: str = "") -> dict:
"""Record an unhandled exception. Returns the (scrubbed) journal entry.
Never raises — this runs inside the global exception handler, where a
second failure would shadow the one being reported.
"""
try:
error_class = classify_exception(exc, trace)
fp = _fingerprint(error_class, exc)
now = time.strftime("%Y-%m-%dT%H:%M:%S")
with _lock:
existing = _entries.get(fp)
if existing:
existing["count"] = int(existing.get("count", 1)) + 1
existing["last_seen"] = now
existing["route"] = scrub_text(route) or existing.get("route", "")
_entries.move_to_end(fp)
entry = existing
else:
entry = {
"fingerprint": fp,
"error_class": error_class,
"type": type(exc).__name__,
"message": scrub_text(str(exc)),
"route": scrub_text(route),
"trace": scrub_text(trace)[:_MAX_TRACE_CHARS],
"first_seen": now,
"last_seen": now,
"count": 1,
}
_entries[fp] = entry
while len(_entries) > _MAX_ENTRIES:
_entries.popitem(last=False)
_persist_locked()
return entry
except Exception:
return {"error_class": "UNKNOWN", "type": type(exc).__name__, "count": 1}
def recent(limit: int = 20) -> list[dict]:
"""Most recent errors first."""
with _lock:
items = list(_entries.values())
return list(reversed(items))[: max(1, min(limit, _MAX_ENTRIES))]
def clear() -> None:
with _lock:
_entries.clear()
try:
os.remove(JOURNAL_PATH)
except OSError:
pass
+106
View File
@@ -0,0 +1,106 @@
"""Privacy scrubber for diagnostic text that may leave the machine.
Everything OmniVoice renders into a bug report or diagnostic dump goes
through ``scrub_text()`` before it can reach a prefilled GitHub Issues URL
(the only outbound path — see CLAUDE.md Capability 2). The scrubber is the
backend twin of ``frontend/src/utils/bugReport.js``'s ``scrubText`` and
must stay at least as strict:
- home directories → ``~`` (macOS ``/Users/<name>``, Linux ``/home/<name>``,
Windows ``C:\\Users\\<name>``, plus the *actual* ``$HOME`` of this process)
- credential-shaped substrings → ``***REDACTED***`` (HF tokens, GitHub
PATs, OpenAI-style ``sk-`` keys)
- values of env vars whose NAME matches ``*TOKEN*|*KEY*|*SECRET*|
*PASSWORD*|*CREDENTIAL*`` — so a stack trace that interpolated a real
secret still comes out clean
Unlike ``core.logging_filter`` (which rewrites log records in-flight and
must stay cheap), this module runs on report-sized strings at report time,
so it can afford the env-var sweep.
"""
from __future__ import annotations
import os
import re
REDACTED = "***REDACTED***"
# Env-var NAMES whose values must never appear in scrubbed output.
_SECRET_NAME_RE = re.compile(r"TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL", re.IGNORECASE)
# Credential-shaped substrings, independent of where they came from.
# Thresholds mirror core.logging_filter: long enough that identifiers like
# `hf_hub` or `sk-learn` survive, short enough that real tokens never do.
_TOKEN_PATTERNS = (
re.compile(r"hf_[A-Za-z0-9]{30,}"), # HuggingFace
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), # GitHub fine-grained PAT
re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}"), # GitHub classic tokens
re.compile(r"sk-[A-Za-z0-9_\-]{20,}"), # OpenAI-style API keys
)
# Home-directory shapes for all three supported platforms. Matched
# pattern-wise (not just this machine's $HOME) so paths quoted from a
# user's pasted log on another OS get cleaned too.
_HOME_PATTERNS = (
re.compile(r"/Users/[^/\s\"']+"), # macOS
re.compile(r"/home/[^/\s\"']+"), # Linux
re.compile(r"[A-Za-z]:\\Users\\[^\\\s\"']+"), # Windows
)
# Values shorter than this are too entropy-poor to be real secrets and too
# likely to shred unrelated text (e.g. PASSWORD_MIN_LENGTH=8 would otherwise
# turn every "8" in the report into ***REDACTED***).
_MIN_SECRET_LEN = 8
def _env_secret_values() -> list[str]:
"""Values of secret-named env vars, longest first so overlapping
values (e.g. a token and its prefix) redact cleanly."""
vals = [
v
for k, v in os.environ.items()
if _SECRET_NAME_RE.search(k) and v and len(v) >= _MIN_SECRET_LEN
]
return sorted(vals, key=len, reverse=True)
def scrub_text(text: str | None) -> str:
"""Return ``text`` with secrets and home paths redacted.
Never raises — scrubbing failure must not block a bug report, and a
partially-scrubbed string is still better than an unscrubbed one, so
each pass is independent.
"""
if not text:
return "" if text is None else str(text)
s = str(text)
# 1. Exact env-var secret values (most specific — run first).
try:
for val in _env_secret_values():
s = s.replace(val, REDACTED)
except Exception:
pass
# 2. Credential-shaped substrings.
for pat in _TOKEN_PATTERNS:
try:
s = pat.sub(REDACTED, s)
except Exception:
pass
# 3. This process's real home dir (covers symlinked/nonstandard homes
# the generic patterns miss), then the per-OS shapes.
try:
home = os.path.expanduser("~")
if home and home not in ("/", "~"):
s = s.replace(home, "~")
except Exception:
pass
for pat in _HOME_PATTERNS:
try:
s = pat.sub("~", s)
except Exception:
pass
return s
+33 -1
View File
@@ -498,6 +498,13 @@ async def global_exception_handler(request: Request, exc: Exception):
except Exception: except Exception:
logger.exception("Failed to write crash log") logger.exception("Failed to write crash log")
logger.exception("Unhandled exception for %s", request.url) logger.exception("Unhandled exception for %s", request.url)
# Structured journal entry (dedup + error_class) — feeds /system/errors/
# recent, the diagnostic bundle, and the bug-report pipeline. record()
# never raises; a journal failure must not shadow the real error.
from core import error_journal
_entry = error_journal.record(
exc, route=str(request.url.path), trace=traceback.format_exc()
)
# CORSMiddleware doesn't always get a shot at `exception_handler`-created # CORSMiddleware doesn't always get a shot at `exception_handler`-created
# responses, which leaves the browser reporting every 500 as a bare CORS # responses, which leaves the browser reporting every 500 as a bare CORS
# error. Attach the headers manually so the real `detail` bubbles up. # error. Attach the headers manually so the real `detail` bubbles up.
@@ -507,7 +514,11 @@ async def global_exception_handler(request: Request, exc: Exception):
headers["Access-Control-Allow-Origin"] = origin headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Credentials"] = "true" headers["Access-Control-Allow-Credentials"] = "true"
headers["Vary"] = "Origin" headers["Vary"] = "Origin"
return JSONResponse({"detail": str(exc)}, status_code=500, headers=headers) return JSONResponse(
{"detail": str(exc), "error_class": _entry.get("error_class")},
status_code=500,
headers=headers,
)
_LOOPBACK_CLIENTS = {"127.0.0.1", "::1"} _LOOPBACK_CLIENTS = {"127.0.0.1", "::1"}
@@ -709,8 +720,29 @@ if __name__ == "__main__":
help="Boot the server, poll /health, exit 0 on success / 1 on timeout. " help="Boot the server, poll /health, exit 0 on success / 1 on timeout. "
"Used by the release-time installer smoke step in .github/workflows/release.yml.", "Used by the release-time installer smoke step in .github/workflows/release.yml.",
) )
parser.add_argument(
"--diagnose",
action="store_true",
help="Run the self-check suite (device, ffmpeg, HF token, disk, engines, "
"network) without starting the server. Exit 0 if healthy, 1 if any "
"check fails. Output is scrubbed — safe to paste into a GitHub issue.",
)
parser.add_argument(
"--deep",
action="store_true",
help="With --diagnose: also load the active TTS engine and synthesize a "
"short utterance. Catches 'installed but broken'. May cold-load the "
"model (minutes + a large download on a fresh install).",
)
args, _unknown = parser.parse_known_args() args, _unknown = parser.parse_known_args()
if args.diagnose:
from core.diagnose import run_diagnostics, format_text
_report = run_diagnostics(deep=args.deep)
print(format_text(_report), flush=True)
sys.exit(0 if _report["summary"]["ok"] else 1)
# Single-sourced from OMNIVOICE_PORT so the bare `python main.py` path and # Single-sourced from OMNIVOICE_PORT so the bare `python main.py` path and
# `--health-check` agree with the Rust sidecar / uvicorn-CLI `--port`. # `--health-check` agree with the Rust sidecar / uvicorn-CLI `--port`.
_port = network_share.backend_port() _port = network_share.backend_port()
+26
View File
@@ -4,6 +4,32 @@ The top 10 errors users have actually hit on `v0.2.x`, with their causes and
fixes. Most have a deeplink anchor that the in-app error UI's "Open docs for fixes. Most have a deeplink anchor that the in-app error UI's "Open docs for
this error" button targets directly. this error" button targets directly.
## Start here: self-diagnosis
<a id="self-diagnosis"></a>
Before digging through the entries below, let the app diagnose itself:
- **In the app:** **Settings → About → "Run self-check"** verifies your
compute device (CUDA/MPS/CPU), ffmpeg, HuggingFace token, disk space,
data-directory permissions, RAM, installed TTS engines, and hub
reachability — each with a hint when something's off.
- **Headless / terminal:**
```bash
uv run python backend/main.py --diagnose # same checks, exits 1 on failure
uv run python backend/main.py --diagnose --deep # also loads the active engine
# and synthesizes a test utterance
```
`--deep` catches "installed but broken" engines. On a fresh install it may
cold-load the model (minutes, plus a large download).
- **Filing an issue?** **Settings → About → "Save diagnostic bundle"**
produces a zip (self-check report, recent classified errors, scrubbed log
tails) you can drag straight onto the GitHub issue. Home paths and
anything token-shaped are redacted before they leave your machine.
## 1. `pkg_resources` missing (ModuleNotFoundError) ## 1. `pkg_resources` missing (ModuleNotFoundError)
<a id="pkg_resources-missing"></a> <a id="pkg_resources-missing"></a>
+8 -2
View File
@@ -49,6 +49,8 @@ import useDubWorkflow from './hooks/useDubWorkflow';
const LazyFallback = () => <div className="app-lazy-fallback">{i18n.t('app.loading')}</div>; const LazyFallback = () => <div className="app-lazy-fallback">{i18n.t('app.loading')}</div>;
import { Toaster, toast } from 'react-hot-toast'; import { Toaster, toast } from 'react-hot-toast';
import { toastErrorWithReport } from './utils/errorToast';
import { addBreadcrumb } from './utils/breadcrumbs';
import { import {
POPULAR_LANGS, POPULAR_ISO, TAGS, CATEGORIES, PRESETS, CLONE_MAX_SECONDS, POPULAR_LANGS, POPULAR_ISO, TAGS, CATEGORIES, PRESETS, CLONE_MAX_SECONDS,
} from './utils/constants'; } from './utils/constants';
@@ -102,6 +104,9 @@ function App() {
}, [locale, theme, font]); }, [locale, theme, font]);
const mode = useAppStore(s => s.mode); const mode = useAppStore(s => s.mode);
const setMode = useAppStore(s => s.setMode); const setMode = useAppStore(s => s.setMode);
// Breadcrumb every view change — mode names are a closed set, so this is
// privacy-safe by construction (see utils/breadcrumbs.js).
useEffect(() => { addBreadcrumb(`view:${mode}`); }, [mode]);
const [navRailSide, setNavRailSide] = useState(() => { const [navRailSide, setNavRailSide] = useState(() => {
try { return localStorage.getItem('omnivoice.navRailSide') || 'left'; } catch { return 'left'; } try { return localStorage.getItem('omnivoice.navRailSide') || 'left'; } catch { return 'left'; }
}); });
@@ -545,6 +550,7 @@ function App() {
}); });
const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => { const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => {
addBreadcrumb('export');
if (e) { e.preventDefault(); e.stopPropagation(); } if (e) { e.preventDefault(); e.stopPropagation(); }
// Browser / Docker web build: there is no Tauri shell, so the native save // Browser / Docker web build: there is no Tauri shell, so the native save
// dialog is unavailable — invoking it throws "Cannot read properties of // dialog is unavailable — invoking it throws "Cannot read properties of
@@ -561,7 +567,7 @@ function App() {
} catch (err) { console.warn('exportRecord (browser export path) failed:', err); } } catch (err) { console.warn('exportRecord (browser export path) failed:', err); }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error(i18n.t('app.toast_export_failed', { message: err?.message || err })); toastErrorWithReport(i18n.t('app.toast_export_failed', { message: err?.message || err }), err);
} }
return; return;
} }
@@ -576,7 +582,7 @@ function App() {
loadExportHistory(); loadExportHistory();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error(i18n.t('app.toast_export_failed', { message: err?.message || err })); toastErrorWithReport(i18n.t('app.toast_export_failed', { message: err?.message || err }), err);
} }
}; };
const revealInFolder = async (filePath) => { const revealInFolder = async (filePath) => {
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Cpu, Mic, MessageSquare, Activity, AlertTriangle, CheckCircle2, RefreshCw, Layers } from 'lucide-react'; import { Cpu, Mic, MessageSquare, Activity, AlertTriangle, CheckCircle2, RefreshCw, Layers } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toastErrorWithReport } from '../utils/errorToast';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { listEngines, getEngineHealth } from '../api/engines'; import { listEngines, getEngineHealth } from '../api/engines';
import { Badge, Button, Segmented, Table } from '../ui'; import { Badge, Button, Segmented, Table } from '../ui';
@@ -126,7 +126,7 @@ export default function EngineCompatibilityMatrix({
} catch (e) { } catch (e) {
const msg = e?.message || String(e); const msg = e?.message || String(e);
setError(msg); setError(msg);
toast.error(t('engines.loadFailed', { message: msg })); toastErrorWithReport(t('engines.loadFailed', { message: msg }), e);
} finally { } finally {
setLoading(false); setLoading(false);
} }
+39 -1
View File
@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { AlertCircle, BookOpen, RefreshCw } from 'lucide-react'; import { AlertCircle, BookOpen, Bug, RefreshCw, Search } from 'lucide-react';
import i18next from 'i18next'; import i18next from 'i18next';
import { classifyError, openDocsFor } from '../utils/errorDocsMap'; import { classifyError, openDocsFor } from '../utils/errorDocsMap';
import { openExternal } from '../api/external';
import { buildBugReportUrl, buildIssueSearchUrl } from '../utils/bugReport';
import './WaveformErrorBoundary.css'; import './WaveformErrorBoundary.css';
export default class ErrorBoundary extends React.Component { export default class ErrorBoundary extends React.Component {
@@ -36,6 +38,26 @@ export default class ErrorBoundary extends React.Component {
} }
}; };
report = async () => {
// Prefilled GitHub Issues URL with the scrubbed error attached — the
// user reviews everything on github.com before anything is submitted.
try {
await openExternal(await buildBugReportUrl({ error: this.state.error }));
} catch (err) {
console.warn('[ErrorBoundary] report failed', err);
}
};
searchIssues = async () => {
// "Has someone already hit this?" — issue search in the browser, so a
// duplicate gets a 👍 on the existing thread instead of a new report.
try {
await openExternal(buildIssueSearchUrl(this.state.error));
} catch (err) {
console.warn('[ErrorBoundary] issue search failed', err);
}
};
render() { render() {
if (!this.state.error) return this.props.children; if (!this.state.error) return this.props.children;
@@ -66,6 +88,22 @@ export default class ErrorBoundary extends React.Component {
> >
<BookOpen size={12} /> {i18next.t('errors.openDocs')} <BookOpen size={12} /> {i18next.t('errors.openDocs')}
</button> </button>
<button
type="button"
onClick={this.searchIssues}
className="btn-secondary errbnd-search"
title={i18next.t('errors.searchIssues')}
>
<Search size={12} /> {i18next.t('errors.searchIssues')}
</button>
<button
type="button"
onClick={this.report}
className="btn-secondary errbnd-report"
title={i18next.t('reportBug.title')}
>
<Bug size={12} /> {i18next.t('errors.report')}
</button>
</div> </div>
</div> </div>
</div> </div>
+8
View File
@@ -457,6 +457,14 @@ export default function LogsFooter() {
className={`logs-footer__notif-item logs-footer__notif-item--${notif.level} ${notif.action ? 'logs-footer__notif-item--clickable' : ''}`} className={`logs-footer__notif-item logs-footer__notif-item--${notif.level} ${notif.action ? 'logs-footer__notif-item--clickable' : ''}`}
onClick={() => { onClick={() => {
if (!notif.action) return; if (!notif.action) return;
// Acting on the crash notice acknowledges it — the backend
// stores the seen crash-log size so it doesn't re-fire
// every session until a NEW crash grows the log.
if (notif.id === 'crash-last-session') {
import('../api/client')
.then(({ API }) => fetch(`${API}/system/crash/ack`, { method: 'POST' }))
.catch(() => {});
}
if (notif.action.type === 'navigate') { if (notif.action.type === 'navigate') {
useAppStore.getState().setMode?.(notif.action.target); useAppStore.getState().setMode?.(notif.action.target);
setCollapsed(true); setCollapsed(true);
+7 -81
View File
@@ -8,73 +8,19 @@
* never POST to GitHub directly, and never bypass the user's review * never POST to GitHub directly, and never bypass the user's review
* opt-in by construction, no separate consent dialog needed. * opt-in by construction, no separate consent dialog needed.
* *
* What gets captured (no secrets): * Capture + scrubbing live in utils/bugReport.js (shared with the
* - OS + arch * ErrorBoundary's report action and error toasts): version, OS, GPU/CPU/
* - OmniVoice version (Vite injects __APP_VERSION__ at build time) * RAM, active TTS engine home paths and credential-shaped strings are
* - Browser/webview UA * redacted, audio contents never included.
* - Active TTS engine (best-effort fetch)
* - Optional user-typed description
*
* What gets stripped:
* - $HOME path ~/
* - Anything matching /TOKEN|KEY|SECRET/i in env vars
* - Audio file contents (we don't include them)
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { Bug } from 'lucide-react'; import { Bug } from 'lucide-react';
import { Button } from '../ui'; import { Button } from '../ui';
import { openExternal } from '../api/external'; import { openExternal } from '../api/external';
import { API } from '../api/client'; import { buildBugReportUrl } from '../utils/bugReport';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__) || 'unknown'; export default function ReportBugButton({ size = 'sm', variant = 'subtle', label, error }) {
const ISSUES_URL = 'https://github.com/debpalash/OmniVoice-Studio/issues/new';
function stripHome(s) {
if (!s) return s;
// Best-effort home redaction works for the most common /Users/<name>/
// and /home/<name>/ paths. We don't know the actual $HOME from JS, so
// pattern-match the prefix.
return String(s)
.replace(/\/Users\/[^/]+/g, '~')
.replace(/\/home\/[^/]+/g, '~')
.replace(/[A-Z]:\\Users\\[^\\]+/g, '~');
}
async function captureContext() {
const lines = [
`**Version:** \`${APP_VERSION}\``,
`**Platform:** \`${navigator?.userAgent || 'unknown'}\``,
];
// Best-effort backend system info silently skip if backend is down.
try {
const r = await fetch(`${API}/system/info`);
if (r.ok) {
const j = await r.json();
// /system/info exposes `platform` (sys.platform) + `device` (best
// compute device). Map to those older field names (os/torch_device/
// gpu) never existed on this endpoint, so they silently dropped.
if (j?.platform) lines.push(`**OS:** \`${j.platform}\``);
if (j?.python) lines.push(`**Python:** \`${j.python}\``);
if (j?.device) lines.push(`**Compute device:** \`${stripHome(j.device)}\``);
}
} catch { /* backend probably not up yet */ }
try {
const r = await fetch(`${API}/engines`);
if (r.ok) {
const j = await r.json();
const active = j?.tts?.active;
if (active) lines.push(`**Active TTS engine:** \`${active}\``);
}
} catch { /* noop */ }
return lines.join('\n');
}
export default function ReportBugButton({ size = 'sm', variant = 'subtle', label }) {
const { t } = useTranslation(); const { t } = useTranslation();
const displayLabel = label || t('reportBug.label'); const displayLabel = label || t('reportBug.label');
const [building, setBuilding] = useState(false); const [building, setBuilding] = useState(false);
@@ -82,27 +28,7 @@ export default function ReportBugButton({ size = 'sm', variant = 'subtle', label
const handleClick = async () => { const handleClick = async () => {
setBuilding(true); setBuilding(true);
try { try {
const ctx = await captureContext(); await openExternal(await buildBugReportUrl({ error }));
const body = [
'<!-- Click Submit at the bottom of this page to file the issue.',
' Review the auto-captured environment info below and add anything',
' about what you were doing when the bug happened. -->',
'',
'## Describe the bug',
'',
'<!-- e.g. "Synthesize failed in Design mode after picking Narrator personality" -->',
'',
'## Environment',
'',
ctx,
'',
'## What I was doing',
'',
'<!-- step-by-step would help us reproduce -->',
'',
].join('\n');
const url = `${ISSUES_URL}?title=${encodeURIComponent('[Bug] ')}&labels=${encodeURIComponent('bug')}&body=${encodeURIComponent(body)}`;
await openExternal(url);
} finally { } finally {
setBuilding(false); setBuilding(false);
} }
+8 -5
View File
@@ -10,6 +10,8 @@ import { apiPost } from '../api/client';
import { API } from '../api/client'; import { API } from '../api/client';
import { playPing, isTauri } from '../utils/media'; import { playPing, isTauri } from '../utils/media';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import { addBreadcrumb } from '../utils/breadcrumbs';
import i18next from 'i18next'; import i18next from 'i18next';
const t = i18next.t.bind(i18next); const t = i18next.t.bind(i18next);
@@ -204,7 +206,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
// ── Handlers ── // ── Handlers ──
const handleDubUpload = useCallback(async (dubVideoFile) => { const handleDubUpload = useCallback(async (dubVideoFile) => {
if (!dubVideoFile) return; if (!dubVideoFile) return;
setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download'); addBreadcrumb('dub:upload'); setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() }); setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() });
const ctrl = new AbortController(); const ctrl = new AbortController();
dubAbortCtrlRef.current = ctrl; dubAbortCtrlRef.current = ctrl;
@@ -229,7 +231,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
} catch (err) { } catch (err) {
setDubPrepStage(null); setDubPrepStage(null);
if (err.name === 'AbortError') { toast(t('dub_workflow.upload_cancelled')); setDubStep('idle'); useAppStore.getState().dismissPill(); } if (err.name === 'AbortError') { toast(t('dub_workflow.upload_cancelled')); setDubStep('idle'); useAppStore.getState().dismissPill(); }
else { setDubError(err.message); setDubStep('idle'); toast.error(t('dub_workflow.upload_failed', { message: err.message })); useAppStore.getState().errorPill(err.message); } else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.upload_failed', { message: err.message }), err); useAppStore.getState().errorPill(err.message); }
setTranscribeStart(null); setTranscribeStart(null);
} finally { dubAbortCtrlRef.current = null; } } finally { dubAbortCtrlRef.current = null; }
}, [setDubStep, setDubError, setDubFailure, setDubTracks, setDubPrepStage, setDubJobId, setDubFilename, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]); }, [setDubStep, setDubError, setDubFailure, setDubTracks, setDubPrepStage, setDubJobId, setDubFilename, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]);
@@ -237,7 +239,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
const handleDubIngestUrl = useCallback(async (url, opts = {}) => { const handleDubIngestUrl = useCallback(async (url, opts = {}) => {
const clean = (url || '').trim(); const clean = (url || '').trim();
if (!clean) return; if (!clean) return;
setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download'); addBreadcrumb('dub:ingest-url'); setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() }); setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() });
const ctrl = new AbortController(); const ctrl = new AbortController();
dubAbortCtrlRef.current = ctrl; dubAbortCtrlRef.current = ctrl;
@@ -261,7 +263,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
} catch (err) { } catch (err) {
setDubPrepStage(null); setDubPrepStage(null);
if (err.name === 'AbortError') { toast(t('dub_workflow.ingest_cancelled')); setDubStep('idle'); useAppStore.getState().dismissPill(); } if (err.name === 'AbortError') { toast(t('dub_workflow.ingest_cancelled')); setDubStep('idle'); useAppStore.getState().dismissPill(); }
else { setDubError(err.message); setDubStep('idle'); toast.error(t('dub_workflow.ingest_failed', { message: err.message })); useAppStore.getState().errorPill(err.message); } else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.ingest_failed', { message: err.message }), err); useAppStore.getState().errorPill(err.message); }
setTranscribeStart(null); setTranscribeStart(null);
} finally { dubAbortCtrlRef.current = null; } } finally { dubAbortCtrlRef.current = null; }
}, [setDubStep, setDubError, setDubFailure, setDubTracks, setDubPrepStage, setDubJobId, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]); }, [setDubStep, setDubError, setDubFailure, setDubTracks, setDubPrepStage, setDubJobId, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]);
@@ -284,7 +286,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
} catch (err) { } catch (err) {
setTranscribeStart(null); setTranscribeStart(null);
if (err.name === 'AbortError') { toast(t('dub_workflow.retry_cancelled')); setDubStep('idle'); } if (err.name === 'AbortError') { toast(t('dub_workflow.retry_cancelled')); setDubStep('idle'); }
else { setDubError(err.message); setDubStep('idle'); toast.error(t('dub_workflow.transcription_failed', { message: err.message })); } else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.transcription_failed', { message: err.message }), err); }
} finally { dubAbortCtrlRef.current = null; } } finally { dubAbortCtrlRef.current = null; }
}, [dubJobId, setDubError, setDubSegments, setDubStep, _waitForTranscribe, loadProjects]); }, [dubJobId, setDubError, setDubSegments, setDubStep, _waitForTranscribe, loadProjects]);
@@ -381,6 +383,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
}, [dubSegments, dubLangCode, translateProvider, translateQuality, glossaryTerms, setIsTranslating, setDubSegments, setDubError]); }, [dubSegments, dubLangCode, translateProvider, translateQuality, glossaryTerms, setIsTranslating, setDubSegments, setDubError]);
const handleDubGenerate = useCallback(async (opts = {}) => { const handleDubGenerate = useCallback(async (opts = {}) => {
addBreadcrumb('dub:generate');
const regenOnly = Array.isArray(opts.regenOnly) && opts.regenOnly.length ? opts.regenOnly : null; const regenOnly = Array.isArray(opts.regenOnly) && opts.regenOnly.length ? opts.regenOnly : null;
const preview = !!opts.preview; const preview = !!opts.preview;
setDubStep('generating'); setDubStep('generating');
+10 -4
View File
@@ -6,6 +6,8 @@ import { probeAudioDuration } from '../utils/format';
import { CLONE_MAX_SECONDS, PRESETS } from '../utils/constants'; import { CLONE_MAX_SECONDS, PRESETS } from '../utils/constants';
import { buildDesignInstruct } from '../utils/voiceInstruct'; import { buildDesignInstruct } from '../utils/voiceInstruct';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import { addBreadcrumb } from '../utils/breadcrumbs';
import i18next from 'i18next'; import i18next from 'i18next';
const t = i18next.t.bind(i18next); const t = i18next.t.bind(i18next);
@@ -69,6 +71,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!text.trim()) return toast.error(t('tts_errors.enter_text')); if (!text.trim()) return toast.error(t('tts_errors.enter_text'));
if (mode === 'clone' && !refAudio && !selectedProfile) return toast.error(t('tts_errors.upload_or_select')); if (mode === 'clone' && !refAudio && !selectedProfile) return toast.error(t('tts_errors.upload_or_select'));
addBreadcrumb(`generate:start (${mode})`);
setIsGenerating(true); setIsGenerating(true);
setGenerationTime(0); setGenerationTime(0);
const st = Date.now(); const st = Date.now();
@@ -156,10 +159,13 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
setSidebarTab('history'); setSidebarTab('history');
playPing(); playPing();
} catch (err) { } catch (err) {
const msg = err?.name === 'AbortError' // Timeouts are user-recoverable (retry / shorter input) — plain toast.
? t('tts_errors.timeout') // Real generation failures get the "Report this bug" action.
: t('tts_errors.error_prefix', { message: err.message }); if (err?.name === 'AbortError') {
toast.error(msg); toast.error(t('tts_errors.timeout'));
} else {
toastErrorWithReport(t('tts_errors.error_prefix', { message: err.message }), err);
}
} finally { } finally {
if (abortTimer) clearTimeout(abortTimer); if (abortTimer) clearTimeout(abortTimer);
clearInterval(timerRef.current); clearInterval(timerRef.current);
+14 -1
View File
@@ -271,6 +271,16 @@
}, },
"about": { "about": {
"app": "App", "app": "App",
"self_check": "Run self-check",
"self_check_failed": "Self-check failed: {{message}}",
"self_check_ok": "OK",
"self_check_warn": "Warning",
"self_check_fail": "Failed",
"self_check_healthy": "All checks passed — this install looks healthy.",
"self_check_attention": "{{count}} check(s) failed — see the hints above.",
"save_bundle": "Save diagnostic bundle",
"bundle_saved": "Diagnostic bundle saved: {{filename}}",
"bundle_failed": "Could not build the diagnostic bundle: {{message}}",
"version": "Version", "version": "Version",
"tauri_runtime": "Tauri runtime", "tauri_runtime": "Tauri runtime",
"platform": "Platform", "platform": "Platform",
@@ -1092,7 +1102,10 @@
"title": "This tab hit a snag.", "title": "This tab hit a snag.",
"desc": "Don't worry — the rest of the app still works. You can switch tabs, or try again below.", "desc": "Don't worry — the rest of the app still works. You can switch tabs, or try again below.",
"tryAgain": "Try again", "tryAgain": "Try again",
"openDocs": "Open docs for this error" "openDocs": "Open docs for this error",
"report": "Report this bug",
"searchIssues": "Search similar issues",
"unexpected": "Unexpected error: {{message}}"
}, },
"common": { "common": {
"open": "Open", "open": "Open",
+4
View File
@@ -15,8 +15,12 @@ import './index.css';
import App from './App.jsx'; import App from './App.jsx';
import RemoteAuthGate from './components/RemoteAuthGate'; import RemoteAuthGate from './components/RemoteAuthGate';
import { installConsoleCapture } from './utils/consoleBuffer.js'; import { installConsoleCapture } from './utils/consoleBuffer.js';
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers.js';
installConsoleCapture(); installConsoleCapture();
// After console capture so the underlying console.error of each uncaught
// failure is already in the ring buffer when the toast appears.
installGlobalErrorHandlers();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
+4 -3
View File
@@ -11,6 +11,7 @@ import {
import { API } from '../api/client'; import { API } from '../api/client';
import BatchAddDialog from '../components/BatchAddDialog'; import BatchAddDialog from '../components/BatchAddDialog';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import './BatchQueue.css'; import './BatchQueue.css';
/** /**
@@ -78,7 +79,7 @@ export default function BatchQueue({ onBack }) {
await enqueueBatchJob(file, langCodes, settings.voiceId || undefined, settings.preserveBg); await enqueueBatchJob(file, langCodes, settings.voiceId || undefined, settings.preserveBg);
success++; success++;
} catch (e) { } catch (e) {
toast.error(t('batch.enqueue_failed', { name: file.name, message: e.message })); toastErrorWithReport(t('batch.enqueue_failed', { name: file.name, message: e.message }), e);
} }
} }
if (success > 0) { if (success > 0) {
@@ -94,7 +95,7 @@ export default function BatchQueue({ onBack }) {
toast.success(t('batch.job_cancelled')); toast.success(t('batch.job_cancelled'));
reload(); reload();
} catch (e) { } catch (e) {
toast.error(t('batch.cancel_failed', { message: e.message })); toastErrorWithReport(t('batch.cancel_failed', { message: e.message }), e);
} }
}, [t, reload]); }, [t, reload]);
@@ -104,7 +105,7 @@ export default function BatchQueue({ onBack }) {
toast.success(t('batch.job_deleted')); toast.success(t('batch.job_deleted'));
reload(); reload();
} catch (e) { } catch (e) {
toast.error(t('batch.delete_failed', { message: e.message })); toastErrorWithReport(t('batch.delete_failed', { message: e.message }), e);
} }
}, [t, reload]); }, [t, reload]);
+3 -1
View File
@@ -20,6 +20,7 @@ import { formatTime } from '../utils/format';
import { API } from '../api/client'; import { API } from '../api/client';
import { listTranslationEngines, installTranslationEngine } from '../api/engines'; import { listTranslationEngines, installTranslationEngine } from '../api/engines';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import { Button, Segmented, Badge, Progress } from '../ui'; import { Button, Segmented, Badge, Progress } from '../ui';
import { openDocsFor, classifyError } from '../utils/errorDocsMap'; import { openDocsFor, classifyError } from '../utils/errorDocsMap';
import GlossaryPanel from '../components/GlossaryPanel'; import GlossaryPanel from '../components/GlossaryPanel';
@@ -213,7 +214,8 @@ export default function DubTab(props) {
toast.success(t('dub.install_ok', { engine: engineId }), { id: progressToast }); toast.success(t('dub.install_ok', { engine: engineId }), { id: progressToast });
} }
} catch (err) { } catch (err) {
toast.error(t('dub.install_failed', { message: String(err.message || err).slice(0, 200) }), { id: progressToast, duration: 8000 }); toast.dismiss(progressToast);
toastErrorWithReport(t('dub.install_failed', { message: String(err.message || err).slice(0, 200) }), err);
} finally { } finally {
setEngineInstalling(null); setEngineInstalling(null);
} }
+88 -1
View File
@@ -14,10 +14,12 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { import {
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink, Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
CheckCircle, AlertCircle, Plug, Download, Copy, Building2, KeyRound, CheckCircle, AlertCircle, Plug, Download, Copy, Building2, KeyRound,
Keyboard, Wifi, Palette, Keyboard, Wifi, Palette, Activity,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { openExternal } from '../api/external'; import { openExternal } from '../api/external';
import { API } from '../api/client';
import { addBreadcrumb } from '../utils/breadcrumbs';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs } from '../api/system'; import { systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs } from '../api/system';
import i18n, { LANGUAGES } from '../i18n'; import i18n, { LANGUAGES } from '../i18n';
@@ -1010,6 +1012,7 @@ export function EnginesTab() {
// its install / GPU / isolation state. // its install / GPU / isolation state.
const onSelect = useCallback(async (family, backendId) => { const onSelect = useCallback(async (family, backendId) => {
try { try {
addBreadcrumb(`engine:${family}=${backendId}`);
const r = await selectEngine(family, backendId); const r = await selectEngine(family, backendId);
toast.success(t('settings.engine_switched', { family: family.toUpperCase(), engine: r.active })); toast.success(t('settings.engine_switched', { family: family.toUpperCase(), engine: r.active }));
} catch (e) { } catch (e) {
@@ -1096,6 +1099,46 @@ export default function Settings() {
// sysinfo polling is now handled by useSysinfo() hook above // sysinfo polling is now handled by useSysinfo() hook above
// Self-check (/system/diagnose) device, ffmpeg, HF token, disk, engines,
// hub reachability. The report comes back pre-scrubbed (backend core/scrub)
// so "Copy" output is safe to paste straight into a GitHub issue.
const [selfCheck, setSelfCheck] = useState(null);
const [selfCheckRunning, setSelfCheckRunning] = useState(false);
const runSelfCheck = useCallback(async () => {
setSelfCheckRunning(true);
try {
const r = await fetch(`${API}/system/diagnose`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
setSelfCheck(await r.json());
} catch (e) {
toast.error(t('about.self_check_failed', { message: e?.message || e }));
} finally {
setSelfCheckRunning(false);
}
}, [t]);
// Diagnostic bundle zip of self-check + error journal + scrubbed log
// tails, saved to the outputs dir and revealed so the user can drag it
// onto a GitHub issue (logs never fit in the prefilled-URL report).
const [bundleBuilding, setBundleBuilding] = useState(false);
const saveDiagnosticBundle = useCallback(async () => {
setBundleBuilding(true);
try {
const r = await fetch(`${API}/system/diagnostic-bundle`, { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const j = await r.json();
toast.success(t('about.bundle_saved', { filename: j.filename }));
try {
const { exportReveal } = await import('../api/exports');
await exportReveal({ path: j.path });
} catch { /* reveal is best-effort — the toast already names the file */ }
} catch (e) {
toast.error(t('about.bundle_failed', { message: e?.message || e }));
} finally {
setBundleBuilding(false);
}
}, [t]);
const copyDiagnostics = useCallback(async () => { const copyDiagnostics = useCallback(async () => {
const nav = typeof navigator !== 'undefined' ? navigator : {}; const nav = typeof navigator !== 'undefined' ? navigator : {};
const ua = nav.userAgent || '—'; const ua = nav.userAgent || '—';
@@ -1414,6 +1457,24 @@ export default function Settings() {
{updateState === 'downloading' ? t('about.downloading') : t('about.check_updates')} {updateState === 'downloading' ? t('about.downloading') : t('about.check_updates')}
</Button> </Button>
)} )}
<Button
variant="subtle"
size="md"
leading={!selfCheckRunning && <Activity size={12} />}
onClick={runSelfCheck}
loading={selfCheckRunning}
>
{t('about.self_check')}
</Button>
<Button
variant="subtle"
size="md"
leading={!bundleBuilding && <Download size={12} />}
onClick={saveDiagnosticBundle}
loading={bundleBuilding}
>
{t('about.save_bundle')}
</Button>
<Button <Button
variant="subtle" variant="subtle"
size="md" size="md"
@@ -1447,6 +1508,32 @@ export default function Settings() {
{t('about.commercial_license')} {t('about.commercial_license')}
</Button> </Button>
</div> </div>
{selfCheck && (
<div className="settings-selfcheck">
{selfCheck.checks.map((c) => (
<Row
key={c.id}
label={c.label}
value={
<span>
<Badge tone={c.status === 'ok' ? 'success' : c.status === 'warn' ? 'warn' : 'danger'}>
{c.status === 'ok'
? <CheckCircle size={11} />
: <AlertCircle size={11} />} {t(`about.self_check_${c.status}`)}
</Badge>
{' '}{c.detail}
{c.hint && <span className="settings-muted"> {c.hint}</span>}
</span>
}
/>
))}
<p className="settings-muted">
{selfCheck.summary.ok
? t('about.self_check_healthy')
: t('about.self_check_attention', { count: selfCheck.summary.failures })}
</p>
</div>
)}
</section> </section>
)} )}
+4 -3
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import { import {
ArrowLeft, Fingerprint, Wand2, Lock, Unlock, Trash2, Play, Save, ArrowLeft, Fingerprint, Wand2, Lock, Unlock, Trash2, Play, Save,
FolderOpen, Volume2, Clock, Pencil, Check, X, Sparkles, FolderOpen, Volume2, Clock, Pencil, Check, X, Sparkles,
@@ -81,7 +82,7 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
setEditing(false); setEditing(false);
toast.success(t('voice_profile.saved')); toast.success(t('voice_profile.saved'));
} catch (e) { } catch (e) {
toast.error(t('voice_profile.save_failed', { message: e.message })); toastErrorWithReport(t('voice_profile.save_failed', { message: e.message }), e);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -104,7 +105,7 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
toast.success(t('voice_profile.deleted')); toast.success(t('voice_profile.deleted'));
onDeleted?.(); onDeleted?.();
} catch (e) { } catch (e) {
toast.error(t('voice_profile.delete_failed', { message: e.message })); toastErrorWithReport(t('voice_profile.delete_failed', { message: e.message }), e);
} }
}; };
@@ -139,7 +140,7 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
setTestAudioUrl(url); setTestAudioUrl(url);
setTimeout(() => testAudioRef.current?.play?.(), 80); setTimeout(() => testAudioRef.current?.play?.(), 80);
} catch (e) { } catch (e) {
toast.error(t('voice_profile.gen_failed', { message: e.message })); toastErrorWithReport(t('voice_profile.gen_failed', { message: e.message }), e);
} finally { } finally {
setTestGenerating(false); setTestGenerating(false);
} }
+46
View File
@@ -0,0 +1,46 @@
/**
* breadcrumbs local-only ring of recent UI actions for bug reports.
*
* The cheapest repro-step generator there is: when a report goes out, the
* last ~20 action names ride along as a "Recent actions" section so the
* maintainer sees "switched engine → started dub → export failed" instead
* of guessing.
*
* Privacy rules (stricter than the scrubber):
* - action NAMES only never text content, file names, paths, or URLs
* - callers pass fixed strings like 'generate:start' or 'view:settings';
* anything dynamic must be from a closed set (mode names, engine ids)
* Lives in memory only never persisted, never sent anywhere except inside
* a report body the user reviews on github.com.
*/
const MAX = 20;
const ring = [];
export function addBreadcrumb(action) {
if (!action) return;
const now = Date.now();
const last = ring[ring.length - 1];
// Collapse immediate repeats (a re-render storm must not flush the ring).
if (last && last.action === action && now - last.t < 2000) {
last.t = now;
return;
}
ring.push({ t: now, action: String(action).slice(0, 60) });
if (ring.length > MAX) ring.shift();
}
export function getBreadcrumbs() {
return ring.slice();
}
/** "12:03:05 view:dub" lines, oldest first — ready for the report body. */
export function formatBreadcrumbs() {
return ring
.map((b) => `${new Date(b.t).toLocaleTimeString('en-GB')} ${b.action}`)
.join('\n');
}
export function clearBreadcrumbs() {
ring.length = 0;
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { addBreadcrumb, getBreadcrumbs, formatBreadcrumbs, clearBreadcrumbs } from './breadcrumbs';
describe('breadcrumbs', () => {
beforeEach(() => clearBreadcrumbs());
it('records actions in order', () => {
addBreadcrumb('view:clone');
addBreadcrumb('generate:start (clone)');
expect(getBreadcrumbs().map((b) => b.action)).toEqual([
'view:clone',
'generate:start (clone)',
]);
});
it('collapses immediate repeats so render storms cannot flush the ring', () => {
addBreadcrumb('view:dub');
addBreadcrumb('view:dub');
addBreadcrumb('view:dub');
expect(getBreadcrumbs()).toHaveLength(1);
});
it('caps the ring at 20', () => {
vi.useFakeTimers();
for (let i = 0; i < 30; i++) {
vi.advanceTimersByTime(3000); // past the repeat-collapse window
addBreadcrumb(`action-${i}`);
}
vi.useRealTimers();
const crumbs = getBreadcrumbs();
expect(crumbs).toHaveLength(20);
expect(crumbs[0].action).toBe('action-10');
});
it('formats one line per crumb', () => {
addBreadcrumb('view:settings');
const out = formatBreadcrumbs();
expect(out).toMatch(/\d{2}:\d{2}:\d{2} view:settings/);
});
it('handles empty ring', () => {
expect(formatBreadcrumbs()).toBe('');
});
});
+171
View File
@@ -0,0 +1,171 @@
/**
* bugReport shared builder for the prefilled GitHub Issues URL.
*
* Single source of truth for everything that can leave the machine as a
* bug report: ReportBugButton (Settings About), the ErrorBoundary's
* "Report this bug" action, and error toasts all funnel through
* `buildBugReportUrl()`. The user always reviews the prefilled form on
* github.com before anything is submitted we never POST, never hold a
* token (CLAUDE.md Capability 2).
*
* `scrubText` is the frontend twin of backend/core/scrub.py and must stay
* at least as strict for the shapes a webview can see (home paths +
* credential-shaped substrings; env vars aren't reachable from JS).
*/
/* global __APP_VERSION__ -- injected by Vite at build time (vite.config define) */
import { API } from '../api/client';
import { formatBreadcrumbs } from './breadcrumbs';
export const ISSUES_URL = 'https://github.com/debpalash/OmniVoice-Studio/issues/new';
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__) || 'unknown';
export const REDACTED = '***REDACTED***';
// Thresholds mirror backend/core/scrub.py: long enough that identifiers
// like `hf_hub` or `sk-learn` survive, short enough that real tokens don't.
const TOKEN_PATTERNS = [
/hf_[A-Za-z0-9]{30,}/g, // HuggingFace
/github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT
/gh[pousr]_[A-Za-z0-9]{30,}/g, // GitHub classic tokens
/sk-[A-Za-z0-9_-]{20,}/g, // OpenAI-style API keys
];
const HOME_PATTERNS = [
/\/Users\/[^/\s"']+/g, // macOS
/\/home\/[^/\s"']+/g, // Linux
/[A-Za-z]:\\Users\\[^\\\s"']+/g, // Windows
];
/** Redact credential-shaped substrings and home directories. */
export function scrubText(text) {
if (text == null) return '';
let s = String(text);
for (const pat of TOKEN_PATTERNS) s = s.replace(pat, REDACTED);
for (const pat of HOME_PATTERNS) s = s.replace(pat, '~');
return s;
}
// GitHub truncates very long prefill URLs; keep the encoded result well
// under the ~8k practical ceiling so the user never loses the form.
const MAX_STACK_CHARS = 1800;
const MAX_BODY_CHARS = 6000;
/** Environment lines for the report body. Best-effort every fetch is
* optional so a dead backend still yields a usable report. */
export async function captureContext() {
const lines = [
`**Version:** \`${APP_VERSION}\``,
`**Platform:** \`${navigator?.userAgent || 'unknown'}\``,
];
try {
const r = await fetch(`${API}/system/info`);
if (r.ok) {
const j = await r.json();
if (j?.os_version) lines.push(`**OS:** \`${scrubText(j.os_version)}\``);
else if (j?.platform) lines.push(`**OS:** \`${j.platform}\``);
if (j?.python) lines.push(`**Python:** \`${j.python}\``);
if (j?.device) lines.push(`**Compute device:** \`${scrubText(j.device)}\``);
if (j?.gpu_name) {
const vram = j?.vram_total_gb ? ` (${j.vram_total_gb} GB VRAM)` : '';
lines.push(`**GPU:** \`${scrubText(j.gpu_name)}${vram}\``);
}
if (j?.cpu_model) lines.push(`**CPU:** \`${scrubText(j.cpu_model)}\``);
if (j?.ram_total_gb) lines.push(`**RAM:** \`${j.ram_total_gb} GB\``);
if (j?.disk_free_gb) lines.push(`**Disk free:** \`${j.disk_free_gb} GB\``);
}
} catch { /* backend probably not up yet */ }
try {
const r = await fetch(`${API}/engines`);
if (r.ok) {
const j = await r.json();
const active = j?.tts?.active;
if (active) lines.push(`**Active TTS engine:** \`${active}\``);
}
} catch { /* noop */ }
return lines.join('\n');
}
/**
* Build the prefilled GitHub Issues URL.
*
* @param {object} [opts]
* @param {string} [opts.title] Issue title prefill (defaults to '[Bug] ').
* @param {Error|string} [opts.error] Error to embed message + stack are
* scrubbed and truncated into an "## Error" section so the report opens
* with the actual failure attached.
*/
export async function buildBugReportUrl({ title = '[Bug] ', error } = {}) {
const ctx = await captureContext();
const errorSection = [];
if (error) {
const msg = scrubText(error?.message || String(error));
// Seed the title with the failure so the issue list stays scannable;
// the user can still edit it on github.com before submitting.
if (title === '[Bug] ' && msg) title = `[Bug] ${msg.slice(0, 80)}`;
let stack = error?.stack ? scrubText(error.stack) : '';
if (stack.length > MAX_STACK_CHARS) stack = `${stack.slice(0, MAX_STACK_CHARS)}\n… (truncated)`;
errorSection.push(
'## Error',
'',
'```',
msg,
...(stack && stack !== msg ? [stack] : []),
'```',
'',
);
}
// Action names only (see utils/breadcrumbs.js privacy rules) — still
// scrubbed as belt-and-braces, and the user reviews it all on github.com.
const crumbs = scrubText(formatBreadcrumbs());
const crumbSection = crumbs
? ['## Recent actions', '', '```', crumbs, '```', '']
: [];
let body = [
'<!-- Click Submit at the bottom of this page to file the issue.',
' Review the auto-captured environment info below and add anything',
' about what you were doing when the bug happened. -->',
'',
'## Describe the bug',
'',
'<!-- e.g. "Synthesize failed in Design mode after picking Narrator personality" -->',
'',
...errorSection,
'## Environment',
'',
ctx,
'',
...crumbSection,
'## What I was doing',
'',
'<!-- step-by-step would help us reproduce -->',
'',
].join('\n');
if (body.length > MAX_BODY_CHARS) body = `${body.slice(0, MAX_BODY_CHARS)}\n… (truncated)`;
return `${ISSUES_URL}?title=${encodeURIComponent(title)}&labels=${encodeURIComponent('bug')}&body=${encodeURIComponent(body)}`;
}
/**
* GitHub issue-search URL for "has someone already hit this?" opened in
* the user's browser before they file a duplicate. Search terms come from
* the scrubbed error message with noise (numbers, paths, quotes) stripped
* so the query matches across machines.
*/
export function buildIssueSearchUrl(error) {
const msg = scrubText(error?.message || String(error || ''));
const terms = msg
.replace(/[^a-zA-Z\s]/g, ' ') // drop numbers/punctuation — machine-specific
.split(/\s+/)
.filter((w) => w.length > 2)
.slice(0, 6)
.join(' ');
const q = `is:issue ${terms}`.trim();
return `https://github.com/debpalash/OmniVoice-Studio/issues?q=${encodeURIComponent(q)}`;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { scrubText, buildBugReportUrl, ISSUES_URL, REDACTED } from './bugReport';
describe('scrubText — frontend twin of backend/core/scrub.py', () => {
it.each([
['/Users/alice/Library/Logs/app.log', '~/Library/Logs/app.log'],
['/home/bob/.omnivoice/omnivoice.log', '~/.omnivoice/omnivoice.log'],
['C:\\Users\\carol\\AppData\\Roaming\\OmniVoice', '~\\AppData\\Roaming\\OmniVoice'],
])('redacts home path %s', (raw, expected) => {
expect(scrubText(raw)).toBe(expected);
});
it.each([
[`hf_${'A'.repeat(34)}`],
[`ghp_${'B'.repeat(36)}`],
[`github_pat_${'C'.repeat(22)}`],
[`sk-${'d'.repeat(40)}`],
])('redacts credential-shaped %s', (secret) => {
const out = scrubText(`auth failed: token=${secret}`);
expect(out).not.toContain(secret);
expect(out).toContain(REDACTED);
});
it.each([['hf_hub'], ['sk-learn'], ['ghp_x']])(
'leaves short identifier %s alone',
(benign) => {
expect(scrubText(`import error in ${benign}`)).toContain(benign);
},
);
it('handles null/undefined', () => {
expect(scrubText(null)).toBe('');
expect(scrubText(undefined)).toBe('');
});
});
describe('buildBugReportUrl', () => {
beforeEach(() => {
// Backend down — the builder must still produce a usable URL.
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
});
it('targets the issues/new endpoint with bug label', async () => {
const url = await buildBugReportUrl();
expect(url.startsWith(`${ISSUES_URL}?`)).toBe(true);
expect(url).toContain(`labels=${encodeURIComponent('bug')}`);
});
it('embeds the scrubbed error message and stack', async () => {
const err = new Error('cannot open /Users/alice/voice.wav');
const url = await buildBugReportUrl({ error: err });
const body = decodeURIComponent(url);
expect(body).toContain('## Error');
expect(body).toContain('cannot open ~/voice.wav');
expect(body).not.toContain('/Users/alice');
});
it('seeds the title with the error message', async () => {
const url = await buildBugReportUrl({ error: new Error('synthesis exploded') });
expect(decodeURIComponent(url)).toContain('[Bug] synthesis exploded');
});
it('stays under the prefill URL ceiling on huge stacks', async () => {
const err = new Error('boom');
err.stack = 'at frame\n'.repeat(5000);
const url = await buildBugReportUrl({ error: err });
expect(url.length).toBeLessThan(8000);
});
});
describe('buildIssueSearchUrl', () => {
it('builds a scrubbed, noise-free search query', async () => {
const { buildIssueSearchUrl } = await import('./bugReport');
const url = buildIssueSearchUrl(new Error('CUDA error 700 at /home/eve/cache: illegal memory access'));
const q = decodeURIComponent(url.split('q=')[1]);
expect(url).toContain('github.com/debpalash/OmniVoice-Studio/issues?q=');
expect(q).toContain('CUDA error');
expect(q).not.toContain('700'); // machine-specific noise stripped
expect(q).not.toContain('/home/eve'); // scrubbed + punctuation-stripped
});
it('survives an empty error', async () => {
const { buildIssueSearchUrl } = await import('./bugReport');
expect(buildIssueSearchUrl(null)).toContain('issues?q=');
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* errorToast error toast with a "Report" action.
*
* Drop-in upgrade for `toast.error(message)` at call sites that have the
* failure in hand: same toast, plus a button that opens the prefilled
* GitHub Issues form (utils/bugReport.js) with the scrubbed error attached.
* Nothing is sent anywhere until the user clicks Submit on github.com.
*/
import toast from 'react-hot-toast';
import i18next from 'i18next';
import { openExternal } from '../api/external';
import { buildBugReportUrl } from './bugReport';
export function toastErrorWithReport(message, error) {
const err = error instanceof Error ? error : new Error(String(error ?? message));
toast.error(
(tst) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1 }}>{message}</span>
<button
type="button"
className="btn-secondary"
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
onClick={async () => {
toast.dismiss(tst.id);
try {
await openExternal(await buildBugReportUrl({ error: err }));
} catch (e) {
// openExternal already falls back to window.open; if even
// that failed there's nothing actionable left to surface.
console.warn('[errorToast] report action failed', e);
}
}}
>
{i18next.t('errors.report')}
</button>
</div>
),
{ duration: 8000 },
);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* globalErrorHandlers last-resort surfacing for uncaught failures.
*
* consoleBuffer already records `window.onerror` / `unhandledrejection`
* into the Settings Logs Frontend ring; this adds the user-visible
* half: a throttled error toast with a "Report this bug" action
* (utils/errorToast.jsx) so async failures outside any ErrorBoundary or
* wired call site still have a path to a GitHub issue.
*
* Throttled per message (one toast per 30s) and filtered against known
* benign noise a render-loop bug must not bury the user in toasts.
*/
import i18next from 'i18next';
import { toastErrorWithReport } from './errorToast';
const THROTTLE_MS = 30_000;
const lastShown = new Map();
// Browser/webview noise that is not actionable by the user and must never
// produce a report prompt.
const IGNORE_PATTERNS = [
/ResizeObserver loop/i,
/AbortError/i,
/Loading chunk \d+ failed/i, // transient on dev-server restarts
/Script error\.?$/i, // opaque cross-origin errors carry no info
];
function shouldShow(message) {
if (!message || IGNORE_PATTERNS.some((p) => p.test(message))) return false;
const key = String(message).slice(0, 200);
const now = Date.now();
if ((lastShown.get(key) || 0) > now - THROTTLE_MS) return false;
lastShown.set(key, now);
return true;
}
function surface(message, error) {
if (!shouldShow(message)) return;
const err = error instanceof Error ? error : new Error(String(error ?? message));
toastErrorWithReport(
i18next.t('errors.unexpected', { message: String(message).slice(0, 140) }),
err,
);
}
let installed = false;
export function installGlobalErrorHandlers() {
if (installed || typeof window === 'undefined') return;
installed = true;
window.addEventListener('error', (e) => {
surface(e?.error?.message || e.message, e.error);
});
window.addEventListener('unhandledrejection', (e) => {
const r = e?.reason;
surface(r?.message || String(r), r);
});
}
+112
View File
@@ -0,0 +1,112 @@
"""core.diagnose — self-check suite behind /system/diagnose and --diagnose."""
import pytest
from core import diagnose
from core.diagnose import OK, WARN, FAIL, run_diagnostics, format_text
@pytest.fixture()
def report():
# include_network=False: the suite must come back instantly offline —
# that's the contract the CLI and tests rely on.
return run_diagnostics(include_network=False)
def test_report_shape(report):
assert set(report) == {"app_version", "platform", "checks", "summary"}
ids = [c["id"] for c in report["checks"]]
assert len(ids) == len(set(ids)), "check ids must be unique"
for c in report["checks"]:
assert c["status"] in (OK, WARN, FAIL)
assert c["label"] and isinstance(c["detail"], str)
def test_network_check_skippable(report):
assert "network" not in [c["id"] for c in report["checks"]]
def test_summary_consistent(report):
s = report["summary"]
statuses = [c["status"] for c in report["checks"]]
assert s["passed"] == statuses.count(OK)
assert s["warnings"] == statuses.count(WARN)
assert s["failures"] == statuses.count(FAIL)
assert s["ok"] == (s["failures"] == 0)
def test_core_checks_present(report):
ids = {c["id"] for c in report["checks"]}
assert {"python", "device", "ffmpeg", "hf_token", "disk", "data_dir", "ram", "engines"} <= ids
def test_low_disk_fails(monkeypatch):
class FakeUsage:
free = 1 * 1024 ** 3 # 1 GB — below the 2 GB fail line
monkeypatch.setattr(diagnose.shutil, "disk_usage", lambda _p: FakeUsage())
check = diagnose._check_disk()
assert check["status"] == FAIL
def test_unwritable_data_dir_fails(monkeypatch, tmp_path):
missing = tmp_path / "definitely" / "not" / "there"
monkeypatch.setattr(diagnose, "DATA_DIR", str(missing))
check = diagnose._check_data_dir()
assert check["status"] == FAIL
assert check["hint"] # actionable hint required on failure
def test_details_are_scrubbed(monkeypatch, tmp_path):
# A DATA_DIR under the user's home must come out as ~/… in the report.
import os
home_dir = os.path.join(os.path.expanduser("~"), ".omnivoice-test-probe")
monkeypatch.setattr(diagnose, "DATA_DIR", home_dir)
check = diagnose._check_disk()
assert os.path.expanduser("~") not in check["detail"]
def test_format_text_ascii_and_exit_signal(report):
text = format_text(report)
# ASCII-only: Windows consoles on legacy code pages must not choke.
text.encode("ascii")
assert "OmniVoice Studio self-check" in text
assert ("looks healthy" in text) == report["summary"]["ok"]
# ── Deep synthesis check (mocked — no real model load in CI) ─────────────
def test_deep_off_by_default(report):
assert "deep_synth" not in [c["id"] for c in report["checks"]]
def test_deep_check_success(monkeypatch):
class FakeBackend:
sample_rate = 24000
def generate(self, text, **kw):
import torch
return torch.zeros(1, 24000) # exactly 1s
import services.tts_backend as tb
monkeypatch.setattr(tb, "get_active_tts_backend", lambda model=None: FakeBackend())
monkeypatch.setattr(tb, "active_backend_id", lambda: "fake")
check = diagnose._check_deep_synthesis()
assert check["status"] == OK
assert "1.0s of audio" in check["detail"]
def test_deep_check_engine_failure(monkeypatch):
import services.tts_backend as tb
def _boom(model=None):
raise RuntimeError("weights corrupted at /home/eve/cache")
monkeypatch.setattr(tb, "get_active_tts_backend", _boom)
check = diagnose._check_deep_synthesis()
assert check["status"] == FAIL
assert "/home/eve" not in check["detail"] # scrubbed
assert check["hint"]
def test_deep_check_skips_during_model_load(monkeypatch):
import services.model_manager as mm
monkeypatch.setattr(mm, "get_model_status", lambda: {"status": "loading"})
check = diagnose._check_deep_synthesis()
assert check["status"] == WARN
assert "skipped" in check["detail"]
+68
View File
@@ -0,0 +1,68 @@
"""core.diagnostic_bundle — the drag-onto-a-GitHub-issue zip."""
import json
import os
import zipfile
import pytest
from core import diagnostic_bundle
from core.diagnostic_bundle import build_bundle
EXPECTED_MEMBERS = {
"meta.json",
"self_check.txt",
"self_check.json",
"errors.json",
"logs/omnivoice.log.txt",
"logs/crash_log.txt",
}
@pytest.fixture()
def bundle_env(monkeypatch, tmp_path):
"""Isolated log files + output dir, with a secret planted in the log."""
log = tmp_path / "omnivoice.log"
log.write_text(
"2026-01-01 INFO startup ok\n"
"2026-01-01 ERROR failed for /home/eve/voice.wav token=hf_"
+ "Z" * 34
+ "\n",
encoding="utf-8",
)
crash = tmp_path / "crash_log.txt"
crash.write_text("--- ts ---\nTraceback from /Users/eve/app\n", encoding="utf-8")
out = tmp_path / "outputs"
monkeypatch.setattr(diagnostic_bundle, "LOG_PATH", str(log))
monkeypatch.setattr(diagnostic_bundle, "CRASH_LOG_PATH", str(crash))
monkeypatch.setattr(diagnostic_bundle, "OUTPUTS_DIR", str(out))
return tmp_path
def test_bundle_members_and_meta(bundle_env):
path = build_bundle(include_network=False)
assert os.path.exists(path)
with zipfile.ZipFile(path) as zf:
assert set(zf.namelist()) == EXPECTED_MEMBERS
meta = json.loads(zf.read("meta.json"))
assert meta["app_version"]
report = json.loads(zf.read("self_check.json"))
assert report["summary"]["passed"] >= 1
def test_bundle_log_tails_are_scrubbed(bundle_env):
path = build_bundle(include_network=False)
with zipfile.ZipFile(path) as zf:
log_tail = zf.read("logs/omnivoice.log.txt").decode()
crash_tail = zf.read("logs/crash_log.txt").decode()
assert "/home/eve" not in log_tail
assert "hf_" + "Z" * 34 not in log_tail
assert "***REDACTED***" in log_tail
assert "/Users/eve" not in crash_tail
def test_bundle_survives_missing_logs(bundle_env, monkeypatch, tmp_path):
monkeypatch.setattr(diagnostic_bundle, "LOG_PATH", str(tmp_path / "missing.log"))
monkeypatch.setattr(diagnostic_bundle, "CRASH_LOG_PATH", str(tmp_path / "missing_crash.txt"))
path = build_bundle(include_network=False)
with zipfile.ZipFile(path) as zf:
assert "(no file at" in zf.read("logs/omnivoice.log.txt").decode()
+93
View File
@@ -0,0 +1,93 @@
"""core.error_journal — structured, deduped, classified backend error ring."""
import json
import os
import pytest
from core import error_journal
from core.error_journal import classify_exception, record, recent
@pytest.fixture(autouse=True)
def isolated_journal(monkeypatch, tmp_path):
"""Point persistence at a temp file and start every test empty."""
monkeypatch.setattr(error_journal, "JOURNAL_PATH", str(tmp_path / "journal.jsonl"))
error_journal._entries.clear()
yield
error_journal._entries.clear()
# ── Classification ────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"exc,trace,expected",
[
(RuntimeError("CUDA out of memory. Tried to allocate 2.5 GiB"), "", "GPU_OOM"),
(RuntimeError("MPS backend out of memory"), "", "GPU_OOM"),
(OSError(28, "No space left on device"), "", "DISK_FULL"),
(RuntimeError("401 Client Error: Unauthorized for url: https://huggingface.co/x"), "", "HF_AUTH_FAILED"),
(RuntimeError("boom"), "huggingface_hub.errors.GatedRepoError: ...", "HF_AUTH_FAILED"),
(FileNotFoundError("No such file or directory: 'ffmpeg'"), "", "FFMPEG_MISSING"),
(ConnectionError("Connection refused"), "", "NETWORK_ERROR"),
(TimeoutError("timed out"), "", "NETWORK_ERROR"),
(ValueError("tensor shape mismatch"), "", "UNKNOWN"),
],
)
def test_classify(exc, trace, expected):
assert classify_exception(exc, trace) == expected
def test_pyannote_needs_auth_marker():
# pyannote alone is any diarization bug — must NOT classify as license.
plain = RuntimeError("pyannote pipeline failed on segment 3")
assert classify_exception(plain, "") != "PYANNOTE_LICENSE_REQUIRED"
gated = RuntimeError("pyannote/speaker-diarization-3.1: 403 gated repo, accept access")
assert classify_exception(gated, "") == "PYANNOTE_LICENSE_REQUIRED"
# ── Recording + dedup ─────────────────────────────────────────────────────
def test_record_and_recent_order():
record(ValueError("first"), route="/a")
record(KeyError("second"), route="/b")
errors = recent()
assert errors[0]["message"].endswith("'second'") or "second" in errors[0]["message"]
assert errors[1]["route"] == "/a"
def test_dedup_bumps_count():
for _ in range(3):
record(ValueError("same failure"), route="/gen")
errors = recent()
assert len(errors) == 1
assert errors[0]["count"] == 3
def test_trace_is_scrubbed_and_truncated():
trace = "File \"/home/eve/app/main.py\" line 1\n" + "x" * 10_000
entry = record(RuntimeError("boom"), trace=trace)
assert "/home/eve" not in entry["trace"]
assert len(entry["trace"]) <= error_journal._MAX_TRACE_CHARS
def test_ring_capped():
for i in range(error_journal._MAX_ENTRIES + 10):
record(ValueError(f"distinct-{i}"))
assert len(recent(limit=50)) == error_journal._MAX_ENTRIES
def test_persists_to_jsonl():
record(ValueError("persisted"))
with open(error_journal.JOURNAL_PATH, encoding="utf-8") as f:
lines = [json.loads(line) for line in f]
assert any("persisted" in e["message"] for e in lines)
def test_record_never_raises(monkeypatch):
# Even with persistence broken, record() must return an entry.
monkeypatch.setattr(error_journal, "JOURNAL_PATH", "/nonexistent/dir/x.jsonl")
entry = record(ValueError("still works"))
assert entry["error_class"] == "UNKNOWN"
assert entry["count"] == 1
+105
View File
@@ -0,0 +1,105 @@
"""core.scrub — privacy scrubber for diagnostic/bug-report text.
The scrubber is the last gate before text can reach a prefilled GitHub
Issues URL, so these tests pin the exact redaction behavior per platform
path style and per credential shape.
"""
import os
import pytest
from core.scrub import scrub_text, REDACTED
# ── Home directory redaction ──────────────────────────────────────────────
@pytest.mark.parametrize(
"raw,expected",
[
("/Users/alice/Library/Logs/app.log", "~/Library/Logs/app.log"),
("/home/bob/.omnivoice/omnivoice.log", "~/.omnivoice/omnivoice.log"),
(r"C:\Users\carol\AppData\Roaming\OmniVoice", r"~\AppData\Roaming\OmniVoice"),
(r"D:\Users\dave\models", r"~\models"),
],
)
def test_home_paths_redacted(raw, expected):
assert scrub_text(raw) == expected
def test_actual_process_home_redacted():
home = os.path.expanduser("~")
assert home not in scrub_text(f"failed to open {home}/some/file.wav")
def test_home_redaction_inside_traceback():
tb = (
'Traceback (most recent call last):\n'
' File "/home/eve/OmniVoice/backend/main.py", line 42, in synth\n'
"FileNotFoundError: /Users/eve/voice.wav not found"
)
out = scrub_text(tb)
assert "/home/eve" not in out
assert "/Users/eve" not in out
assert 'File "~/OmniVoice/backend/main.py"' in out
# ── Credential-shaped substrings ──────────────────────────────────────────
@pytest.mark.parametrize(
"secret",
[
"hf_" + "A" * 34, # HuggingFace token
"ghp_" + "B" * 36, # GitHub classic PAT
"github_pat_" + "C" * 22, # GitHub fine-grained PAT
"sk-" + "d" * 40, # OpenAI-style key
],
)
def test_tokens_redacted(secret):
out = scrub_text(f"auth failed with token={secret} (401)")
assert secret not in out
assert REDACTED in out
@pytest.mark.parametrize(
"benign",
["hf_hub", "hf_pipeline_load", "sk-learn", "ghp_x"],
)
def test_short_identifiers_survive(benign):
# Identifiers shorter than real-token length must NOT be clobbered —
# they're exactly what makes a stack trace debuggable.
assert benign in scrub_text(f"import error in {benign} module")
# ── Env-var secret values ─────────────────────────────────────────────────
def test_env_secret_value_redacted(monkeypatch):
monkeypatch.setenv("TRANSLATE_API_KEY", "super-secret-value-123")
out = scrub_text("request failed: api_key=super-secret-value-123 rejected")
assert "super-secret-value-123" not in out
assert REDACTED in out
def test_env_secret_short_value_not_swept(monkeypatch):
# A short value would shred unrelated text (every "yes" in the report).
monkeypatch.setenv("SOME_PASSWORD", "yes")
assert scrub_text("yes, the export worked") == "yes, the export worked"
def test_env_non_secret_name_untouched(monkeypatch):
monkeypatch.setenv("OMNIVOICE_MODEL", "k2-fsa/OmniVoice")
assert "k2-fsa/OmniVoice" in scrub_text("loading k2-fsa/OmniVoice")
# ── Robustness ────────────────────────────────────────────────────────────
def test_none_and_empty():
assert scrub_text(None) == ""
assert scrub_text("") == ""
def test_non_string_coerced():
assert scrub_text(42) == "42"