fix: TTS hot-swap actually works + GUI max-width + Python builder discoverable
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run

ROOT CAUSE ANALYSIS

User reported: (a) TTS dropdown doesn't apply, (b) GUI feels stretched,
(c) no link to command builder, (d) Python doesn't even start.

Issues (a), (b), (c) addressed here. (d) addressed in python repo.

=== Fix A: TTS hot-swap (was completely broken) ===

Two distinct bugs:
1. `tts::init_backend()` only read JARVIS_TTS env var, NEVER consulted
   `Settings.tts_backend` in the DB. So saving "Silero" in the GUI
   dropdown changed nothing — not even on restart.
2. `BACKEND: OnceCell<...>` couldn't be replaced at runtime, so even
   if init had picked the right backend, the dropdown was useless
   mid-session.

Fixed:
- `tts::BACKEND` is now `Lazy<RwLock<Option<Arc<dyn TtsBackend>>>>`.
- `choose_backend_name()` resolves from DB → env → auto-detect.
- New `swap_to(name)` replaces the live backend atomically.
- IPC: new `IpcAction::SwitchTts { backend }`. jarvis-app reacts to
  it the same way it reacts to SwitchLlm.
- GUI's `set_tts_backend` now persists + hot-swaps GUI's TTS + Svelte
  page also fires `switchDaemonTts()` over WS so the running daemon
  installs the new backend.
- Removed "Применится при следующем запуске" hint — now applies
  immediately. RU + EN locale strings updated.

=== Fix B: GUI max-width ===

main + #header had no max-width, so on a 1920px monitor the labels
sprawled across 1860px of space, which felt stretched. Added
max-width: 1280px + margin: 0 auto + box-sizing: border-box. Smaller
windows are unaffected.

Also added the same cap on .app-container.assist-page so the home
screen arc reactor centres properly.

=== Fix C: Command builder discoverable ===

The Python fork ships a separate yaml-editing tool at
`python/tools/command_builder/` (pywebview GUI). No way to find it
from the main Rust GUI. Fixed:

- New tauri command `open_command_builder` (crates/jarvis-gui/src/
  tauri_commands/builder.rs). Locates the Python fork via
  $JARVIS_PYTHON_DIR → sibling python/ → C:\Jarvis\python, prefers
  the project's .venv, spawns `python -m tools.command_builder`.
- New blue "Конструктор команд (Python)" button on /settings page
  near the Save / Back buttons.

Tests: 140 rust + 104 python all pass. Release builds of jarvis-app
and jarvis-gui both green.
This commit is contained in:
Bossiara13 2026-05-24 22:12:04 +03:00
parent 05b75feee4
commit 6729e53be6
12 changed files with 206 additions and 29 deletions

View file

@ -153,7 +153,7 @@ settings-ai-tips = Tips
settings-llm-backend = LLM backend
settings-llm-backend-desc = Where to run LLM requests. Hot-swap, no restart needed.
settings-tts-backend = TTS backend
settings-tts-backend-desc = Takes effect on next jarvis-app launch.
settings-tts-backend-desc = Applied immediately — no restart needed.
settings-llm-context = Conversation context
settings-llm-context-desc = LLM remembers previous turns. Reset if answers get weird.
settings-llm-reset = Reset context

View file

@ -153,7 +153,7 @@ settings-ai-tips = Подсказки
settings-llm-backend = LLM движок
settings-llm-backend-desc = Где исполнять LLM-запросы. Hot-swap, без рестарта.
settings-tts-backend = TTS движок
settings-tts-backend-desc = Применится при следующем запуске jarvis-app.
settings-tts-backend-desc = Применяется сразу — без перезапуска.
settings-llm-context = Контекст разговора
settings-llm-context-desc = LLM помнит предыдущие реплики. Сбросить, если ответы становятся странными.
settings-llm-reset = Сбросить контекст

View file

@ -76,6 +76,10 @@ pub enum IpcAction {
// Reload LLM backend from settings DB (after GUI changed it independently).
ReloadLlm,
// Daemon-side TTS hot-swap. GUI fires this after persisting to DB so the
// running daemon installs the new backend without a restart.
SwitchTts { backend: String },
// Request a health snapshot — daemon responds via IpcEvent::HealthSnapshot.
QueryHealth,
}

View file

@ -12,7 +12,6 @@
//!
//! If `JARVIS_TTS` is unset, the dispatcher auto-detects Piper, otherwise SAPI.
use once_cell::sync::OnceCell;
use std::path::Path;
use std::sync::Arc;
@ -84,11 +83,41 @@ pub trait TtsBackend: Send + Sync {
fn speak(&self, text: &str, opts: &SpeakOpts);
}
static BACKEND: OnceCell<Arc<dyn TtsBackend>> = OnceCell::new();
// Live backend swap: stored behind an RwLock<Arc<...>> so reads are cheap
// (clone the Arc) and writes can replace the whole backend instance
// without invalidating outstanding speaks. The OnceCell pattern we used
// before couldn't be replaced at runtime — the GUI's TTS dropdown ended up
// only persisting the choice without applying it. Now `swap_to(name)`
// installs a fresh backend immediately.
static BACKEND: once_cell::sync::Lazy<parking_lot::RwLock<Option<Arc<dyn TtsBackend>>>> =
once_cell::sync::Lazy::new(|| parking_lot::RwLock::new(None));
/// Get the active TTS backend. Initializes once on first call.
/// Get the active TTS backend. Initialises lazily on first call. The choice
/// comes from (in order): persisted Settings DB → `JARVIS_TTS` env var → auto.
pub fn backend() -> Arc<dyn TtsBackend> {
BACKEND.get_or_init(init_backend).clone()
if let Some(b) = BACKEND.read().clone() {
return b;
}
let chosen = choose_backend_name();
let inst = build_backend(&chosen);
*BACKEND.write() = Some(inst.clone());
inst
}
/// Swap to a named backend at runtime — used by the GUI's TTS dropdown.
/// `name` is "sapi" / "piper" / "silero" / "" or "auto" (re-runs auto-detect).
/// Returns the resolved backend name on success, error string otherwise.
pub fn swap_to(name: &str) -> Result<&'static str, String> {
let normalised = name.trim().to_lowercase();
let target = match normalised.as_str() {
"" | "auto" => choose_backend_name(),
"sapi" | "piper" | "silero" => normalised,
other => return Err(format!("unknown TTS backend '{}'", other)),
};
let inst = build_backend(&target);
let resolved = inst.name();
*BACKEND.write() = Some(inst);
Ok(resolved)
}
/// Speak a text via the active backend. Applies sanitisation unless `opts.raw` is set.
@ -109,19 +138,35 @@ pub fn speak_default(text: &str) {
speak(text, &SpeakOpts::default());
}
fn init_backend() -> Arc<dyn TtsBackend> {
let choice = std::env::var("JARVIS_TTS").ok()
/// Resolve the backend name from (DB → env → auto). Pure logic, no instantiation.
fn choose_backend_name() -> String {
// 1. Persisted Settings DB. Empty string means "auto".
if let Some(db) = crate::DB.get() {
let from_db = db.read().tts_backend.trim().to_lowercase();
if !from_db.is_empty() {
return from_db;
}
}
// 2. JARVIS_TTS env var.
let from_env = std::env::var("JARVIS_TTS").ok()
.map(|s| s.trim().to_lowercase())
.unwrap_or_default();
if !from_env.is_empty() {
return from_env;
}
// 3. Auto-detect: prefer Piper if installed, else SAPI.
if PiperBackend::try_new().is_ok() { "piper".into() } else { "sapi".into() }
}
match choice.as_str() {
fn build_backend(choice: &str) -> Arc<dyn TtsBackend> {
match choice {
"piper" => match PiperBackend::try_new() {
Ok(b) => {
info!("TTS backend: Piper ({} / {})", b.binary_display(), b.voice_display());
return Arc::new(b);
}
Err(e) => {
warn!("JARVIS_TTS=piper requested but unavailable ({}). Falling back to SAPI.", e);
warn!("Piper requested but unavailable ({}). Falling back to SAPI.", e);
}
},
"silero" => match SileroBackend::try_new() {
@ -130,21 +175,11 @@ fn init_backend() -> Arc<dyn TtsBackend> {
return Arc::new(b);
}
Err(e) => {
warn!("JARVIS_TTS=silero requested but unavailable ({}). Falling back to SAPI.", e);
warn!("Silero requested but unavailable ({}). Falling back to SAPI.", e);
}
},
"sapi" | "" => {
// Auto-detect: prefer Piper if installed, else SAPI.
if choice.is_empty() {
if let Ok(b) = PiperBackend::try_new() {
info!("TTS backend: Piper (auto-detected, {} / {})", b.binary_display(), b.voice_display());
return Arc::new(b);
}
}
}
other => {
warn!("Unknown JARVIS_TTS value '{}' — using SAPI.", other);
}
"sapi" => {}
other => warn!("Unknown TTS choice '{}' — using SAPI.", other),
}
info!("TTS backend: SAPI (Windows built-in).");