Skip to content

Commit 6fbc5c9

Browse files
luongndclaude
andcommitted
feat(v1.2.15): network/i18n resilience + AI-optional UX polish
- STT: Soniox terminal error (402 budget) stops recording with a clean toast instead of leaking into the transcript - Network: clean "no internet" diagnose message; active ping flips the offline banner on upload/job failure; topnav status chip reflects internet loss (⚠ Mất mạng) - Transcript: open saved meetings at page 1 reliably (positioning tied to load, not stale store); auto-route to Recording tab when a meeting has no minutes - AI optional: Settings AI section marked optional; Model auto-disabled until an API key exists; minutes CTA + Minutes tab guide the user to Settings when AI is unconfigured - UI: minutes language/template moved to an on-demand popup (compact bar); redesigned test-connection results (status badges, no redundancy); update section flattened to a divided list - i18n: reconnect / STT / delete-tooltip / upload messages now follow the language toggle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 509e376 commit 6fbc5c9

22 files changed

Lines changed: 1120 additions & 226 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "voicescribe",
33
"private": true,
4-
"version": "1.2.14",
4+
"version": "1.2.15",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-python/api/diagnose.py

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,21 @@ def set_diarizer(d) -> None:
5353

5454

5555
async def _probe_network_once() -> None:
56-
"""Single TCP connect → 1.1.1.1:443 — writes result into _network_cache."""
56+
"""Deduped probe for the poll loop — skips if another probe is in-flight.
57+
58+
Use for /health (high-frequency, fine to coalesce). For an explicit
59+
re-check that MUST return a fresh value, call _do_probe() directly."""
5760
if _network_cache["checking"]:
5861
return
62+
await _do_probe()
63+
64+
65+
async def _do_probe() -> None:
66+
"""Single TCP connect → 1.1.1.1:443 — writes result into _network_cache.
67+
68+
Always runs a real probe (no dedup guard). Concurrent calls are safe: the
69+
cache writes are idempotent and last-write-wins is acceptable for a
70+
reachability flag."""
5971
_network_cache["checking"] = True
6072
started = time.monotonic()
6173
try:
@@ -111,13 +123,54 @@ async def health():
111123

112124
@router.get("/ping-network")
113125
async def ping_network():
114-
"""Force a fresh network probe (bypass cache) — used when the frontend
115-
explicitly wants to re-check after a suspected outage. NEVER call from
116-
polling loops; use /health for that."""
117-
await _probe_network_once()
126+
"""Force a fresh network probe (bypass cache AND the in-flight dedup
127+
guard) — used when the frontend explicitly wants to re-check after a
128+
suspected outage (e.g. an upload chunk just failed). NEVER call from
129+
polling loops; use /health for that.
130+
131+
Calls _do_probe() directly so the result is GUARANTEED fresh even if a
132+
/health background probe happens to be running at the same moment —
133+
otherwise _probe_network_once() would early-return on the `checking`
134+
guard and we'd hand back a stale `online: true`."""
135+
await _do_probe()
118136
return await _get_network_status()
119137

120138

139+
# ─── Error classification ──────────────────────────────────────────────────
140+
# When the machine is offline, provider SDKs raise raw DNS/socket errors like
141+
# "[Errno 8] nodename nor servname provided, or not known" (macOS),
142+
# "[Errno -2] Name or service not known" (Linux), or "getaddrinfo failed"
143+
# (Windows). Leaking these into the UI is noise — the real cause is "no
144+
# internet". Detect that class so /diagnose can show a clean offline message.
145+
_NETWORK_ERROR_MARKERS = (
146+
"nodename nor servname", # macOS getaddrinfo EAI_NONAME
147+
"name or service not known", # Linux EAI_NONAME
148+
"getaddrinfo failed", # Windows
149+
"temporary failure in name resolution",
150+
"errno 8", # EAI_NONAME numeric (macOS)
151+
"errno -2", # EAI_NONAME numeric (Linux)
152+
"errno -3", # EAI_AGAIN (DNS temp fail)
153+
"11001", # Windows WSAHOST_NOT_FOUND
154+
"failed to establish a new connection",
155+
"max retries exceeded",
156+
"connection refused",
157+
"network is unreachable",
158+
"no route to host",
159+
"name resolution",
160+
)
161+
162+
163+
def _is_network_error(exc: Exception) -> bool:
164+
"""True if `exc` looks like an offline / DNS-resolution / unreachable error
165+
rather than an auth or server-side problem."""
166+
import socket
167+
# socket.gaierror is the canonical DNS resolution failure.
168+
if isinstance(exc, (socket.gaierror, ConnectionError)):
169+
return True
170+
msg = str(exc).lower()
171+
return any(marker in msg for marker in _NETWORK_ERROR_MARKERS)
172+
173+
121174
@router.get("/diarizer-status")
122175
async def diarizer_status():
123176
if _diarizer is None:
@@ -152,6 +205,21 @@ async def diagnose(lang: str = "vi"):
152205

153206
stt_provider = db.get_setting("stt_provider") or "nvidia"
154207

208+
# ── Offline short-circuit ──────────────────────────────────────────────
209+
# Probe the network FIRST (force-fresh via _do_probe, not cached). If the
210+
# machine is offline, every provider check below would just raise a raw
211+
# DNS error. Return one clean "no internet" message for both STT + LLM.
212+
await _do_probe()
213+
if _network_cache["online"] is False:
214+
offline_msg = t("network_offline", lang)
215+
log.info("STATUS: diagnose short-circuit — network offline")
216+
return {
217+
"stt": {"status": "error", "message": offline_msg, "offline": True},
218+
"llm": {"status": "error", "message": offline_msg, "offline": True},
219+
"backend": stt_provider,
220+
"network": {"online": False},
221+
}
222+
155223
if stt_provider == "soniox":
156224
soniox_key = db.get_setting("soniox_api_key") or os.getenv("SONIOX_API_KEY", "")
157225
if not soniox_key:
@@ -166,7 +234,10 @@ def _test_soniox():
166234
await loop.run_in_executor(None, _test_soniox)
167235
results["stt"] = {"status": "ok", "message": t("soniox_connected", lang)}
168236
except Exception as e:
169-
results["stt"] = {"status": "error", "message": f"{t('soniox_connect_fail', lang)}: {str(e)[:80]}"}
237+
if _is_network_error(e):
238+
results["stt"] = {"status": "error", "message": t("network_offline", lang), "offline": True}
239+
else:
240+
results["stt"] = {"status": "error", "message": f"{t('soniox_connect_fail', lang)}: {str(e)[:80]}"}
170241
else:
171242
nvidia_key = db.get_setting("nvidia_api_key") or os.getenv("NVIDIA_API_KEY", "")
172243
if not nvidia_key:
@@ -192,7 +263,9 @@ def _test_riva():
192263
results["stt"] = {"status": "ok", "message": t("nvidia_connected", lang)}
193264
except Exception as e:
194265
err_str = str(e)
195-
if "TimeoutError" in type(e).__name__ or "timed out" in err_str.lower():
266+
if _is_network_error(e):
267+
results["stt"] = {"status": "error", "message": t("network_offline", lang), "offline": True}
268+
elif "TimeoutError" in type(e).__name__ or "timed out" in err_str.lower():
196269
results["stt"] = {"status": "error", "message": t("nvidia_connect_fail", lang) + ": Connection timed out (10s)"}
197270
else:
198271
results["stt"] = {"status": "error", "message": f"{t('nvidia_connect_fail', lang)}: {err_str[:80]}"}
@@ -222,8 +295,12 @@ def _test_riva():
222295
results["llm"] = {"status": "ok", "message": t("llm_connected", lang)}
223296
else:
224297
results["llm"] = {"status": "error", "message": t("llm_key_invalid", lang)}
225-
except Exception:
226-
results["llm"] = {"status": "error", "message": t("llm_connect_fail", lang)}
298+
except Exception as e:
299+
if _is_network_error(e):
300+
results["llm"] = {"status": "error", "message": t("network_offline", lang), "offline": True}
301+
else:
302+
results["llm"] = {"status": "error", "message": t("llm_connect_fail", lang)}
227303

228304
results["backend"] = stt_provider
305+
results["network"] = {"online": True}
229306
return results

src-python/i18n.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
"groq_key_missing": "Chưa nhập mã truy cập Groq",
1414
"groq_key_invalid": "Mã truy cập Groq không hợp lệ",
1515
"groq_connect_fail": "Không thể kết nối đến Groq",
16-
"llm_connected": "Trợ lý AI đã kết nối thành công",
16+
"llm_connected": "Đã kết nối thành công",
1717
"llm_key_missing": "Chưa nhập mã truy cập AI",
1818
"llm_key_invalid": "Mã truy cập AI không hợp lệ",
1919
"llm_connect_fail": "Không thể kết nối đến dịch vụ AI",
20+
"network_offline": "Không có kết nối mạng — kiểm tra Internet rồi thử lại",
2021

2122
# ── Startup logs ──
2223
"starting": "VoiceScribe dang khoi dong...",
@@ -41,10 +42,11 @@
4142
"groq_key_missing": "Groq access key not set",
4243
"groq_key_invalid": "Invalid Groq access key",
4344
"groq_connect_fail": "Cannot connect to Groq",
44-
"llm_connected": "AI assistant connected successfully",
45+
"llm_connected": "Connected successfully",
4546
"llm_key_missing": "AI access key not set",
4647
"llm_key_invalid": "Invalid AI access key",
4748
"llm_connect_fail": "Cannot connect to AI service",
49+
"network_offline": "No internet connection — check your network and retry",
4850

4951
# ── Startup logs ──
5052
"starting": "VoiceScribe starting...",

src-python/main.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,39 @@ async def _send_results():
659659
# Stream ended — flush any pending translation
660660
break
661661
try:
662+
# Terminal error from Soniox SDK (e.g. 402 budget exhausted,
663+
# auth failure). Surface as a structured error payload so the
664+
# frontend can stop recording + toast — NOT as transcript text.
665+
# Before fix v1.2.15: this branch was missing, so the error
666+
# text leaked through as a regular "System" speaker segment.
667+
if result.get("error"):
668+
err_text = result.get("text", "STT error")
669+
try:
670+
await websocket.send_json({
671+
"error": True,
672+
"terminal": True,
673+
"text": err_text,
674+
"is_final": True,
675+
"speaker": "System",
676+
"speaker_id": -1,
677+
})
678+
except Exception:
679+
pass
680+
log.warning("[ws:soniox-stream] Terminal error sent to FE: %s", err_text)
681+
break # Stream is dead — stop consuming the queue.
682+
# Reconnect heartbeat — forward as info so FE shows
683+
# "🔄 Reconnecting…" instead of injecting a transcript part.
684+
if result.get("info") or result.get("type") == "info":
685+
try:
686+
await websocket.send_json({
687+
"type": "info",
688+
"info": True,
689+
"text": result.get("text", "Reconnecting..."),
690+
"is_final": False,
691+
})
692+
except Exception:
693+
pass
694+
continue
662695
msg = {
663696
"text": result["text"],
664697
"is_final": result["is_final"],

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "voicescribe"
3-
version = "1.2.14"
3+
version = "1.2.15"
44
description = "Scribble - Meeting Minutes"
55
authors = ["you"]
66
edition = "2021"

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Scribble",
4-
"version": "1.2.14",
4+
"version": "1.2.15",
55
"identifier": "com.scribble.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

src/App.tsx

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ToastProvider } from './components/Toast';
1010
import { UpdateChecker } from './components/UpdateChecker';
1111
import { StartupStatusBar } from './components/StartupStatusBar';
1212
import { SIDECAR_HTTP_BASES } from './lib/sidecar';
13+
import { getSettings } from './lib/api';
1314
import './index.css';
1415

1516
const queryClient = new QueryClient();
@@ -65,7 +66,7 @@ function handleTopnavMouseDown(e: React.MouseEvent<HTMLElement>) {
6566
}
6667

6768
function AppInner() {
68-
const { currentView, settingsOpen, setSettingsOpen, lang, setLang, recording, setBackendOnline, setNetworkOnline } = useAppStore();
69+
const { currentView, settingsOpen, setSettingsOpen, lang, setLang, recording, setBackendOnline, setNetworkOnline, networkOnline, setAiConfigured } = useAppStore();
6970
const [backendStatus, setBackendStatusLocal] = useState<'online' | 'offline'>('offline');
7071
// Mirror local backend state into the global store so unrelated components
7172
// (MeetingList action buttons, RecordingBar) can gate themselves without
@@ -98,14 +99,27 @@ function AppInner() {
9899
return () => clearInterval(id);
99100
}, [isOffline]);
100101

102+
// Chip reflects TWO independent signals: the local sidecar (backendStatus)
103+
// AND internet reachability (networkOnline). "Ready" must mean both are up —
104+
// otherwise the user sees a green "Sẵn sàng" while uploads silently fail
105+
// because wifi dropped. networkOnline === null = unknown (sidecar down or
106+
// not probed yet) → don't raise a false offline alarm.
107+
const chipState: 'online' | 'network-offline' | 'starting' =
108+
backendStatus !== 'online'
109+
? 'starting'
110+
: (networkOnline === false ? 'network-offline' : 'online');
111+
101112
const backendLabel = useMemo(() => {
102-
if (backendStatus === 'online') {
113+
if (chipState === 'online') {
103114
return lang === 'vi' ? '✓ Sẵn sàng' : '✓ Ready';
104115
}
116+
if (chipState === 'network-offline') {
117+
return lang === 'vi' ? '⚠ Mất mạng' : '⚠ Offline';
118+
}
105119
// Elapsed time lives in the bottom status strip — no need to also
106120
// surface it here (duplicate signal, eyes have to scan two corners).
107121
return lang === 'vi' ? 'Đang khởi động' : 'Starting';
108-
}, [backendStatus, lang]);
122+
}, [chipState, lang]);
109123

110124
useEffect(() => {
111125
if (window.__TAURI_INTERNALS__) {
@@ -214,6 +228,29 @@ function AppInner() {
214228
return () => { active = false; };
215229
}, []);
216230

231+
// Resolve whether the AI assistant is configured enough to summarize. Shared
232+
// via the store so RecordingBar (CTA) and MeetingDetail (Minutes tab) can
233+
// gate themselves consistently. Re-checks once the sidecar is up and again
234+
// whenever the Settings panel closes (the user may have just added/removed
235+
// AI credentials).
236+
useEffect(() => {
237+
if (backendStatus !== 'online' || settingsOpen) return;
238+
let cancelled = false;
239+
(async () => {
240+
try {
241+
const s = await getSettings();
242+
if (cancelled) return;
243+
const hasKey = !!(s.llm_api_key && String(s.llm_api_key).trim());
244+
const hasModel = !!(s.llm_model && String(s.llm_model).trim());
245+
const hasBaseUrl = !!(s.llm_base_url && String(s.llm_base_url).trim());
246+
const provider = s.llm_provider || 'openai';
247+
const credOk = hasKey || (provider === 'compatible' && hasBaseUrl);
248+
setAiConfigured(credOk && hasModel);
249+
} catch { /* keep previous value on transient failure */ }
250+
})();
251+
return () => { cancelled = true; };
252+
}, [backendStatus, settingsOpen, setAiConfigured]);
253+
217254
return (
218255
<QueryClientProvider client={queryClient}>
219256
<div className={`app ${isOffline ? 'app--offline' : ''} ${IS_MACOS ? 'app--macos' : ''}`}>
@@ -241,12 +278,12 @@ function AppInner() {
241278
</div>
242279
<div className="topnav-right" data-tauri-drag-region>
243280
<div
244-
className={`backend-status-chip ${backendStatus}`}
281+
className={`backend-status-chip ${chipState}`}
245282
title={backendLabel}
246283
aria-live="polite"
247284
data-tauri-drag-region
248285
>
249-
<span className={`status-dot ${backendStatus === 'online' ? 'online' : 'offline'}`} />
286+
<span className={`status-dot ${chipState}`} />
250287
<span>{backendLabel}</span>
251288
</div>
252289
{/* Language + Settings stay enabled during startup — users

0 commit comments

Comments
 (0)