fix: GUI max-width on wrapper + visible TTS swap result + warning cleanup
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

GUI MAX-WIDTH (was still stretched)

Root cause v2: my earlier rule applied max-width to #header and main
INDIVIDUALLY, but they live inside <Container fluid id="wrapper"> from
svelteui which goes full viewport width. Both children inherited that
full width from the flex layout. Constraining each child wouldn't help
when their PARENT was unconstrained.

Fix: max-width:1280px !important on #wrapper itself, with margin:0 auto.
Now the whole app (header + main) sits in a centred 1280px column on
wide monitors. !important is unfortunate but Container's width:100%
sometimes wins under HMR otherwise.

VISIBLE TTS SWAP RESULT (was failing silently)

User saved 'Piper' in dropdown → green 'Saved!' banner → active engine
chip still showed 'sapi'. Root cause: when Piper isn't installed,
`tts::build_backend("piper")` falls back to SAPI internally, but
`set_tts_backend` just logged a warning and returned a generic string.
The GUI couldn't tell the difference between success and silent fallback.

Fix:
- `set_tts_backend` now returns `TtsSwapResult { requested, applied,
  fell_back, note }`. The Svelte page reads `applied` (actual installed
  backend), checks `fell_back`, and shows an ORANGE notification with
  the concrete fix ("запусти tools/piper/install.ps1 чтобы скачать
  бинарь") instead of a green 'saved' lie.
- Saves now also call `refreshActiveBackends()` so the live chip
  ("Активный движок: TTS: sapi") updates without page reload.
- Green notification when swap succeeded normally.

WARNING CLEANUP

- jarvis-app/main.rs: removed unreachable `_ => {}` arm (all IpcAction
  variants are explicit now).
- jarvis-app/tray.rs: moved `info!("Tray initialized.")` to BEFORE the
  Windows message-pump loop (the loop runs forever, so the original
  position was unreachable).
- jarvis-gui/main.rs: dropped unused #[macro_use] on simple_log.
- jarvis-gui/events.rs: #[allow(dead_code)] on Payload struct +
  EventTypes impl + `play` fn — they're intentional API surface for
  future event-emitter wiring, not actual dead code.
- Plus a `cargo fix` pass for misc unused imports.

Down from 12 warnings to 1 (npm-build-completion notice — informational).

Tests: 140 rust + 115 python (was 104, +11 for new wave59_handlers).
Release builds of jarvis-app + jarvis-gui both green.
This commit is contained in:
Bossiara13 2026-05-24 23:11:49 +03:00
parent 944cfcc891
commit 965441d4db
12 changed files with 110 additions and 37 deletions

View file

@ -2,7 +2,6 @@ use std::sync::mpsc::Receiver;
use std::time::SystemTime; use std::time::SystemTime;
use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, audio_processing::vad::listen_window::{ListenWindow, WindowDecision}, audio_processing::vad::webrtc::WebRtcVad, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n, slots}; use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, audio_processing::vad::listen_window::{ListenWindow, WindowDecision}, audio_processing::vad::webrtc::WebRtcVad, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n, slots};
use rand::seq::SliceRandom;
use crate::should_stop; use crate::should_stop;

View file

@ -240,7 +240,6 @@ fn main() -> Result<(), String> {
version: config::APP_VERSION.map(|s| s.to_string()), version: config::APP_VERSION.map(|s| s.to_string()),
}); });
} }
_ => {}
} }
}); });

View file

@ -7,10 +7,8 @@ use tray_icon::{
use image; use image;
use std::process::Command; use std::process::Command;
#[cfg(target_os="windows")]
use winit::platform::windows::EventLoopBuilderExtWindows;
use jarvis_core::{config, i18n, voices, ipc::{self, IpcEvent}, SettingsManager}; use jarvis_core::{i18n, voices, ipc::{self, IpcEvent}, SettingsManager};
const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png"); const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png");
@ -29,6 +27,7 @@ pub fn init_blocking(settings: SettingsManager) {
.unwrap(); .unwrap();
let menu_channel = MenuEvent::receiver(); let menu_channel = MenuEvent::receiver();
info!("Tray initialized.");
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
@ -77,8 +76,6 @@ pub fn init_blocking(settings: SettingsManager) {
std::thread::sleep(std::time::Duration::from_millis(50)); std::thread::sleep(std::time::Duration::from_millis(50));
} }
} }
info!("Tray initialized.");
} }
fn handle_menu_event(event: &MenuEvent, settings: &SettingsManager, tray_state: &menu::TrayState) { fn handle_menu_event(event: &MenuEvent, settings: &SettingsManager, tray_state: &menu::TrayState) {

View file

@ -1,7 +1,6 @@
use tray_icon::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tray_icon::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use jarvis_core::{i18n, voices, SettingsManager}; use jarvis_core::{i18n, voices, SettingsManager};
use jarvis_core::config::structs::{WakeWordEngine, NoiseSuppressionBackend};
// RADIO GROUP // RADIO GROUP

View file

@ -1,4 +1,4 @@
use fluent_bundle::{FluentBundle, FluentResource, FluentArgs, FluentValue}; use fluent_bundle::{FluentResource, FluentArgs, FluentValue};
use fluent_bundle::concurrent::FluentBundle as ConcurrentFluentBundle; use fluent_bundle::concurrent::FluentBundle as ConcurrentFluentBundle;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use parking_lot::RwLock; use parking_lot::RwLock;

View file

@ -1,6 +1,6 @@
use super::client::ChatMessage; use super::client::ChatMessage;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
pub struct ConversationHistory { pub struct ConversationHistory {
system: Option<ChatMessage>, system: Option<ChatMessage>,

View file

@ -1,6 +1,9 @@
use tauri::Emitter; use tauri::Emitter;
// the payload type must implement `Serialize` and `Clone`. // the payload type must implement `Serialize` and `Clone`.
// Kept around as the canonical shape for future event emitter wiring even
// though nothing currently emits one — silence the dead-code lint.
#[allow(dead_code)]
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
pub struct Payload { pub struct Payload {
pub data: String, pub data: String,
@ -16,6 +19,7 @@ pub enum EventTypes {
CommandEnd, CommandEnd,
} }
#[allow(dead_code)]
impl EventTypes { impl EventTypes {
pub fn get(&self) -> &str { pub fn get(&self) -> &str {
match self { match self {
@ -29,6 +33,7 @@ impl EventTypes {
} }
} }
#[allow(dead_code)]
pub fn play(phrase: &str, app_handle: &tauri::AppHandle) { pub fn play(phrase: &str, app_handle: &tauri::AppHandle) {
app_handle app_handle
.emit( .emit(

View file

@ -3,7 +3,6 @@
use jarvis_core::{config, db, i18n, voices, DB, SettingsManager}; use jarvis_core::{config, db, i18n, voices, DB, SettingsManager};
#[macro_use]
extern crate simple_log; extern crate simple_log;
mod events; mod events;

View file

@ -46,8 +46,22 @@ pub fn set_llm_backend(name: String) -> Result<String, String> {
/// Set TTS backend. Persists to settings DB AND hot-swaps the live 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). /// (in the GUI process; the running daemon picks it up via IPC SwitchTts).
///
/// Returns a struct describing what ACTUALLY happened — `requested` is what
/// the user asked for, `applied` is what the engine actually installed. They
/// differ when, e.g., Piper was requested but `piper.exe` isn't on disk; the
/// engine falls back to SAPI to keep speech working, and the GUI shows a
/// warning instead of pretending the swap succeeded.
#[derive(Serialize)]
pub struct TtsSwapResult {
pub requested: String,
pub applied: String,
pub fell_back: bool,
pub note: Option<String>,
}
#[tauri::command] #[tauri::command]
pub fn set_tts_backend(name: String) -> Result<String, String> { pub fn set_tts_backend(name: String) -> Result<TtsSwapResult, String> {
let normalized = match name.trim().to_lowercase().as_str() { let normalized = match name.trim().to_lowercase().as_str() {
"" | "auto" => "".to_string(), "" | "auto" => "".to_string(),
"sapi" | "piper" | "silero" => name.to_lowercase(), "sapi" | "piper" | "silero" => name.to_lowercase(),
@ -67,18 +81,34 @@ pub fn set_tts_backend(name: String) -> Result<String, String> {
return 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 // 2. Apply RIGHT NOW in this GUI process AND report what actually got
// but don't fail the call, the DB change already happened). // installed. The fallback path in build_backend() may silently swap
let apply_target = if normalized.is_empty() { "auto" } else { normalized.as_str() }; // Piper→SAPI when the Piper binary is missing; without surfacing that
if let Err(e) = jarvis_core::tts::swap_to(apply_target) { // the user sees "saved" but the active engine label stays SAPI and
log::warn!("TTS hot-swap in GUI failed (DB still updated): {}", e); // they don't know why.
} let requested = if normalized.is_empty() { "auto".to_string() } else { normalized.clone() };
let applied = match jarvis_core::tts::swap_to(&requested) {
Ok(name) => name.to_string(),
Err(e) => {
log::warn!("TTS hot-swap failed: {}", e);
jarvis_core::tts::backend().name().to_string()
}
};
// 3. The running jarvis-app daemon is a SEPARATE process with its own let fell_back = applied.eq_ignore_ascii_case("sapi")
// TTS instance; it picks up the change via the IpcAction::SwitchTts && !requested.eq_ignore_ascii_case("sapi")
// message — fired from the Svelte page via sendAction() WebSocket. && requested != "auto";
let note = if fell_back {
Some(match requested.as_str() {
"piper" => "Piper не установлен. Запусти tools/piper/install.ps1 чтобы скачать бинарь и голос.".into(),
"silero" => "Silero недоступен. Нужен Python + torch + silero_tts.py helper.".into(),
other => format!("'{}' недоступен — откатился на SAPI.", other),
})
} else {
None
};
Ok(if normalized.is_empty() { "auto".into() } else { normalized }) Ok(TtsSwapResult { requested, applied, fell_back, note })
} }
/// Reset conversation context (clears LLM history turns, keeps system prompt). /// Reset conversation context (clears LLM history turns, keeps system prompt).

View file

@ -2,8 +2,6 @@ use sysinfo::{System, Pid, ProcessRefreshKind, RefreshKind, CpuRefreshKind, Comp
use peak_alloc::PeakAlloc; use peak_alloc::PeakAlloc;
use std::sync::Mutex; use std::sync::Mutex;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use std::process::Command;
use std::env;
#[global_allocator] #[global_allocator]
static PEAK_ALLOC: PeakAlloc = PeakAlloc; static PEAK_ALLOC: PeakAlloc = PeakAlloc;

View file

@ -61,22 +61,29 @@ a {
margin: 0; margin: 0;
} }
#header, // Cap the WHOLE app (header + main) to 1280px on wide monitors and centre it,
main { // so labels in the nav don't sprawl across 1920px+ screens. The wrapper is
padding: 0 30px; // `<Container fluid id="wrapper">` from svelteui fluid keeps it width: 100%
// Cap content width on wide monitors so long labels don't stretch // inside the viewport, then we shrink the inner content here. Smaller windows
// across 1900px+ screens. On smaller windows this is a no-op. // are unaffected (max-width is a ceiling, not a floor).
max-width: 1280px; //
margin: 0 auto; // `!important` is unfortunate but svelteui's Container emits a width:100%
// rule that sometimes outweighs ours under HMR. The override is loud and
// localised only on the top-level wrapper.
#wrapper {
max-width: 1280px !important;
margin: 0 auto !important;
box-sizing: border-box;
}
#header,
main {
padding: 0 30px;
box-sizing: border-box; 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 { .app-container.assist-page {
width: 100%; width: 100%;
max-width: 1280px;
margin: 0 auto; margin: 0 auto;
} }

View file

@ -138,12 +138,30 @@
} }
} }
interface TtsSwapResult {
requested: string
applied: string
fell_back: boolean
note: string | null
}
let ttsSwapInfo: TtsSwapResult | null = null
async function applyTtsBackend(target: string) { async function applyTtsBackend(target: string) {
backendSwapError = ""
ttsSwapInfo = null
try { try {
// 1. GUI Tauri command — persists to DB + hot-swaps GUI's TTS. // 1. GUI Tauri command — persists to DB + hot-swaps GUI's TTS.
await invoke<string>("set_tts_backend", { name: target }) // Returns the actual installed backend (may differ from
// requested if Piper/Silero unavailable → fell back to SAPI).
const res = await invoke<TtsSwapResult>("set_tts_backend", { name: target })
ttsSwapInfo = res
// 2. Tell the running daemon (separate process) to switch too. // 2. Tell the running daemon (separate process) to switch too.
switchDaemonTts(target || "auto") switchDaemonTts(target || "auto")
// 3. Refresh the "Активный движок" chip — without this, the user
// saves Piper, sees "saved!", but the active label stays SAPI
// because we never re-read the live engine.
await refreshActiveBackends()
} catch (err) { } catch (err) {
backendSwapError = String(err) backendSwapError = String(err)
} }
@ -453,9 +471,31 @@
> >
{backendSwapError} {backendSwapError}
</Notification> </Notification>
<Space h="md" />
{/if} {/if}
<Space h="md" /> {#if ttsSwapInfo && ttsSwapInfo.fell_back}
<Notification
title="TTS откатился на {ttsSwapInfo.applied.toUpperCase()}"
color="orange"
withCloseButton={true}
on:close={() => { ttsSwapInfo = null }}
>
Запросил <strong>{ttsSwapInfo.requested}</strong> — недоступен.
{#if ttsSwapInfo.note}<br/>{ttsSwapInfo.note}{/if}
</Notification>
<Space h="md" />
{:else if ttsSwapInfo && !ttsSwapInfo.fell_back}
<Notification
title="TTS переключён на {ttsSwapInfo.applied.toUpperCase()}"
color="teal"
withCloseButton={true}
on:close={() => { ttsSwapInfo = null }}
>
Применено сразу — без перезапуска.
</Notification>
<Space h="md" />
{/if}
<InputWrapper label={t('settings-llm-context') || 'Контекст разговора'}> <InputWrapper label={t('settings-llm-context') || 'Контекст разговора'}>
<Text size="sm" color="gray"> <Text size="sm" color="gray">