From 6729e53be68c522259dd0980eb7fa176e56d7404 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sun, 24 May 2026 22:12:04 +0300 Subject: [PATCH] fix: TTS hot-swap actually works + GUI max-width + Python builder discoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>>>`. - `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. --- crates/jarvis-app/src/main.rs | 7 ++ crates/jarvis-core/src/i18n/locales/en.ftl | 2 +- crates/jarvis-core/src/i18n/locales/ru.ftl | 2 +- crates/jarvis-core/src/ipc/events.rs | 4 + crates/jarvis-core/src/tts/mod.rs | 77 ++++++++++++++----- crates/jarvis-gui/src/main.rs | 3 + crates/jarvis-gui/src/tauri_commands.rs | 4 + .../jarvis-gui/src/tauri_commands/backends.rs | 21 ++++- .../jarvis-gui/src/tauri_commands/builder.rs | 68 ++++++++++++++++ frontend/src/css/styles.scss | 14 ++++ frontend/src/lib/ipc.ts | 6 ++ frontend/src/routes/settings/index.svelte | 27 ++++++- 12 files changed, 206 insertions(+), 29 deletions(-) create mode 100644 crates/jarvis-gui/src/tauri_commands/builder.rs diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index af7e272..8643cc9 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -216,6 +216,13 @@ fn main() -> Result<(), String> { warn!("LLM reload failed: {}", e); } } + IpcAction::SwitchTts { backend } => { + info!("Received SwitchTts IPC: {}", backend); + match jarvis_core::tts::swap_to(&backend) { + Ok(name) => info!("TTS swapped via IPC → {}", name), + Err(e) => warn!("TTS swap via IPC failed: {}", e), + } + } IpcAction::QueryHealth => { let model = jarvis_core::llm::current().map(|c| c.model().to_string()); ipc::send(IpcEvent::HealthSnapshot { diff --git a/crates/jarvis-core/src/i18n/locales/en.ftl b/crates/jarvis-core/src/i18n/locales/en.ftl index 5cdf5a5..14b31a6 100644 --- a/crates/jarvis-core/src/i18n/locales/en.ftl +++ b/crates/jarvis-core/src/i18n/locales/en.ftl @@ -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 diff --git a/crates/jarvis-core/src/i18n/locales/ru.ftl b/crates/jarvis-core/src/i18n/locales/ru.ftl index faee050..5b379b3 100644 --- a/crates/jarvis-core/src/i18n/locales/ru.ftl +++ b/crates/jarvis-core/src/i18n/locales/ru.ftl @@ -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 = Сбросить контекст diff --git a/crates/jarvis-core/src/ipc/events.rs b/crates/jarvis-core/src/ipc/events.rs index afe2e57..b99b0c3 100644 --- a/crates/jarvis-core/src/ipc/events.rs +++ b/crates/jarvis-core/src/ipc/events.rs @@ -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, } \ No newline at end of file diff --git a/crates/jarvis-core/src/tts/mod.rs b/crates/jarvis-core/src/tts/mod.rs index 2738b9e..2cda4a5 100644 --- a/crates/jarvis-core/src/tts/mod.rs +++ b/crates/jarvis-core/src/tts/mod.rs @@ -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> = OnceCell::new(); +// Live backend swap: stored behind an RwLock> 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>>> = + 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 { - 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 { - 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 { + 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 { 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)."); diff --git a/crates/jarvis-gui/src/main.rs b/crates/jarvis-gui/src/main.rs index 2a21206..53eb980 100644 --- a/crates/jarvis-gui/src/main.rs +++ b/crates/jarvis-gui/src/main.rs @@ -139,6 +139,9 @@ fn main() { tauri_commands::plugins_set_enabled, tauri_commands::plugins_open_folder, + // Python command builder launcher + tauri_commands::open_command_builder, + // Wake-word trainer wizard tauri_commands::wake_trainer_status, tauri_commands::wake_trainer_defaults, diff --git a/crates/jarvis-gui/src/tauri_commands.rs b/crates/jarvis-gui/src/tauri_commands.rs index 6556ff0..4d1dab0 100644 --- a/crates/jarvis-gui/src/tauri_commands.rs +++ b/crates/jarvis-gui/src/tauri_commands.rs @@ -63,6 +63,10 @@ pub use profile::*; mod plugins; pub use plugins::*; +// Python command builder launcher +mod builder; +pub use builder::*; + // Wake-word training wizard mod wake_trainer; pub use wake_trainer::*; \ No newline at end of file diff --git a/crates/jarvis-gui/src/tauri_commands/backends.rs b/crates/jarvis-gui/src/tauri_commands/backends.rs index 65a448f..802f7c5 100644 --- a/crates/jarvis-gui/src/tauri_commands/backends.rs +++ b/crates/jarvis-gui/src/tauri_commands/backends.rs @@ -44,8 +44,8 @@ pub fn set_llm_backend(name: String) -> Result { .map_err(|e| e.to_string()) } -/// Set TTS backend. Persists to settings DB. Effective on next jarvis-app start -/// (TTS backend is OnceCell, can't hot-swap mid-process). +/// Set TTS backend. Persists to settings DB AND hot-swaps the live process +/// (in the GUI process; the running daemon picks it up via IPC SwitchTts). #[tauri::command] pub fn set_tts_backend(name: String) -> Result { let normalized = match name.trim().to_lowercase().as_str() { @@ -54,6 +54,7 @@ pub fn set_tts_backend(name: String) -> Result { other => return Err(format!("unknown TTS backend: '{}'", other)), }; + // 1. Persist to DB so a restart picks it up. if let Some(db) = jarvis_core::DB.get() { let snapshot = { let mut s = db.write(); @@ -62,10 +63,22 @@ pub fn set_tts_backend(name: String) -> Result { }; jarvis_core::db::save_settings(&snapshot) .map_err(|e| format!("failed to persist: {}", e))?; - Ok(if normalized.is_empty() { "auto".into() } else { normalized }) } else { - Err("settings DB not initialised".into()) + return Err("settings DB not initialised".into()); } + + // 2. Apply RIGHT NOW in this GUI process (best-effort — log on failure + // but don't fail the call, the DB change already happened). + let apply_target = if normalized.is_empty() { "auto" } else { normalized.as_str() }; + if let Err(e) = jarvis_core::tts::swap_to(apply_target) { + log::warn!("TTS hot-swap in GUI failed (DB still updated): {}", e); + } + + // 3. The running jarvis-app daemon is a SEPARATE process with its own + // TTS instance; it picks up the change via the IpcAction::SwitchTts + // message — fired from the Svelte page via sendAction() WebSocket. + + Ok(if normalized.is_empty() { "auto".into() } else { normalized }) } /// Reset conversation context (clears LLM history turns, keeps system prompt). diff --git a/crates/jarvis-gui/src/tauri_commands/builder.rs b/crates/jarvis-gui/src/tauri_commands/builder.rs new file mode 100644 index 0000000..f71660a --- /dev/null +++ b/crates/jarvis-gui/src/tauri_commands/builder.rs @@ -0,0 +1,68 @@ +//! Tauri command for launching the Python `command_builder` GUI. +//! +//! The Python fork ships a separate yaml-editing tool at +//! `python/tools/command_builder/` (a pywebview-based GUI). Without a launcher +//! button in the main Tauri GUI the user has no easy way to find it. +//! +//! Resolution order for the python checkout: +//! 1. `JARVIS_PYTHON_DIR` env override +//! 2. Sibling `python/` directory next to this jarvis-rust checkout +//! 3. `C:\Jarvis\python` (the dev box default) +//! +//! We don't ship the Python fork inside the Rust binary — this launcher just +//! tries to spawn it. If Python isn't installed, we open the docs URL instead. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn locate_python_dir() -> Option { + if let Ok(p) = std::env::var("JARVIS_PYTHON_DIR") { + let pp = PathBuf::from(p); + if pp.is_dir() { + return Some(pp); + } + } + if let Ok(exe) = std::env::current_exe() { + let mut dir = exe.parent().map(|p| p.to_path_buf()); + for _ in 0..6 { + if let Some(d) = &dir { + let candidate = d.join("python"); + if candidate.join("tools").join("command_builder").is_dir() { + return Some(candidate); + } + dir = d.parent().map(|p| p.to_path_buf()); + } + } + } + let fallback = PathBuf::from(r"C:\Jarvis\python"); + if fallback.join("tools").join("command_builder").is_dir() { + return Some(fallback); + } + None +} + +/// Find a python interpreter to run the builder with. Prefers the project's +/// `.venv\Scripts\python.exe` (where all deps are installed), then `python`. +fn python_bin(py_dir: &Path) -> PathBuf { + let venv = py_dir.join(".venv").join("Scripts").join("python.exe"); + if venv.is_file() { + venv + } else { + PathBuf::from("python") + } +} + +#[tauri::command] +pub fn open_command_builder() -> Result { + let py_dir = locate_python_dir() + .ok_or_else(|| "Python fork not found. Set JARVIS_PYTHON_DIR or install at C:\\Jarvis\\python.".to_string())?; + let py = python_bin(&py_dir); + + Command::new(&py) + .args(["-m", "tools.command_builder"]) + .current_dir(&py_dir) + .spawn() + .map_err(|e| format!("Failed to launch command builder: {} (using {})", e, py.display()))?; + + Ok(format!("Launched from {}", py_dir.display())) +} diff --git a/frontend/src/css/styles.scss b/frontend/src/css/styles.scss index 80d8319..e72001b 100644 --- a/frontend/src/css/styles.scss +++ b/frontend/src/css/styles.scss @@ -64,6 +64,20 @@ a { #header, main { padding: 0 30px; + // Cap content width on wide monitors so long labels don't stretch + // across 1900px+ screens. On smaller windows this is a no-op. + max-width: 1280px; + margin: 0 auto; + box-sizing: border-box; +} + +// The home page's arc reactor + status bar can keep their own internal +// max-width while the page itself can still go full width — the page +// layout below `main` already handles its own centering via flex. +.app-container.assist-page { + width: 100%; + max-width: 1280px; + margin: 0 auto; } // ### FORM OVERRIDES diff --git a/frontend/src/lib/ipc.ts b/frontend/src/lib/ipc.ts index f20202d..2894137 100644 --- a/frontend/src/lib/ipc.ts +++ b/frontend/src/lib/ipc.ts @@ -215,6 +215,12 @@ export function reloadDaemonLlm(): boolean { return sendAction("reload_llm") } +// Hot-swap daemon's TTS backend over IPC. Use "" or "auto" for re-resolve. +// Returns false if daemon not connected. +export function switchDaemonTts(backend: string): boolean { + return sendAction("switch_tts", { backend }) +} + // Ask daemon for a runtime snapshot. Daemon will respond with a // "health_snapshot" event which fills the `daemonHealth` store. export function queryDaemonHealth(): boolean { diff --git a/frontend/src/routes/settings/index.svelte b/frontend/src/routes/settings/index.svelte index 9635cde..daed11f 100644 --- a/frontend/src/routes/settings/index.svelte +++ b/frontend/src/routes/settings/index.svelte @@ -7,7 +7,7 @@ import { showInExplorer } from "@/functions" import { appInfo, assistantVoice, translations, translate, - switchDaemonLlm, reloadDaemonLlm, + switchDaemonLlm, reloadDaemonLlm, switchDaemonTts, } from "@/stores" import HDivider from "@/components/elements/HDivider.svelte" @@ -140,7 +140,10 @@ async function applyTtsBackend(target: string) { try { + // 1. GUI Tauri command — persists to DB + hot-swaps GUI's TTS. await invoke("set_tts_backend", { name: target }) + // 2. Tell the running daemon (separate process) to switch too. + switchDaemonTts(target || "auto") } catch (err) { backendSwapError = String(err) } @@ -426,7 +429,7 @@ { label: 'Silero (PyTorch helper)', value: "silero" }, ]} label={t('settings-tts-backend') || 'TTS движок'} - description={t('settings-tts-backend-desc') || 'Применится при следующем запуске jarvis-app.'} + description={t('settings-tts-backend-desc') || 'Применяется сразу — без перезапуска.'} variant="filled" bind:value={selectedTtsBackend} on:change={() => applyTtsBackend(selectedTtsBackend)} @@ -672,6 +675,26 @@ + + + +