fix: TTS hot-swap actually works + GUI max-width + Python builder discoverable
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:
parent
05b75feee4
commit
6729e53be6
12 changed files with 206 additions and 29 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = Сбросить контекст
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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).");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
@ -44,8 +44,8 @@ pub fn set_llm_backend(name: String) -> Result<String, String> {
|
|||
.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<String, String> {
|
||||
let normalized = match name.trim().to_lowercase().as_str() {
|
||||
|
|
@ -54,6 +54,7 @@ pub fn set_tts_backend(name: String) -> Result<String, String> {
|
|||
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<String, String> {
|
|||
};
|
||||
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).
|
||||
|
|
|
|||
68
crates/jarvis-gui/src/tauri_commands/builder.rs
Normal file
68
crates/jarvis-gui/src/tauri_commands/builder.rs
Normal file
|
|
@ -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<PathBuf> {
|
||||
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<String, String> {
|
||||
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()))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string>("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 @@
|
|||
|
||||
<Space h="sm" />
|
||||
|
||||
<Button
|
||||
color="blue"
|
||||
radius="md"
|
||||
size="sm"
|
||||
uppercase
|
||||
fullSize
|
||||
on:click={async () => {
|
||||
try {
|
||||
const msg = await invoke<string>("open_command_builder")
|
||||
backendSwapError = "Конструктор команд: " + msg
|
||||
} catch (e) {
|
||||
backendSwapError = "Не удалось запустить конструктор: " + String(e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Конструктор команд (Python)
|
||||
</Button>
|
||||
|
||||
<Space h="sm" />
|
||||
|
||||
<Button
|
||||
color="gray"
|
||||
radius="md"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue