Compare commits
4
Commits
main
...
feat/diagnostics
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362dca9ae1 | ||
|
|
bbac66ef76 | ||
|
|
3d74611be8 | ||
|
|
ee08f56e1b |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) ────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user