diff --git a/Cargo.lock b/Cargo.lock index b790613..42e3bf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1265,6 +1265,12 @@ dependencies = [ "dasp_sample", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deranged" version = "0.5.5" @@ -3053,6 +3059,7 @@ dependencies = [ name = "jarvis-core" version = "0.1.0" dependencies = [ + "futures-util", "hound", "intent-classifier", "kira", @@ -3071,6 +3078,7 @@ dependencies = [ "serde_yaml", "sha2", "tokio", + "tokio-tungstenite", "toml 0.9.10+spec-1.1.0", "vosk", ] @@ -5739,6 +5747,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -6839,6 +6858,18 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -7077,6 +7108,23 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index 7ec0318..53db821 100644 --- a/crates/jarvis-app/src/app.rs +++ b/crates/jarvis-app/src/app.rs @@ -1,8 +1,10 @@ use std::time::SystemTime; -use jarvis_core::{audio, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent}; +use jarvis_core::{audio, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, ipc::{self, IpcEvent}}; use rand::prelude::*; +use crate::should_stop; + pub fn start() -> Result<(), ()> { // start the loop main_loop() @@ -29,8 +31,18 @@ fn main_loop() -> Result<(), ()> { } } + // notify GUI we're ready + ipc::send(IpcEvent::Idle); + // the loop 'wake_word: loop { + // check for stop signal + if should_stop() { + info!("Stop signal received, shutting down..."); + ipc::send(IpcEvent::Stopping); + break; + } + // read from microphone recorder::read_microphone(&mut frame_buffer); @@ -45,6 +57,9 @@ fn main_loop() -> Result<(), ()> { // recognize wake-word match listener::data_callback(&frame_buffer) { Some(_keyword_index) => { + // notify GUI + ipc::send(IpcEvent::WakeWordDetected); + // reset some things stt::reset_wake_recognizer(); stt::reset_speech_recognizer(); @@ -64,8 +79,16 @@ fn main_loop() -> Result<(), ()> { .unwrap() ))); + // notify GUI we're listening + ipc::send(IpcEvent::Listening); + // wait for voice commands 'voice_recognition: loop { + // check for stop + if should_stop() { + break 'wake_word; + } + // read from microphone recorder::read_microphone(&mut frame_buffer); @@ -88,6 +111,11 @@ fn main_loop() -> Result<(), ()> { // something was recognized info!("Recognized voice: {}", recognized_voice); + // notify GUI + ipc::send(IpcEvent::SpeechRecognized { + text: recognized_voice.clone(), + }); + // filter recognized voice // @TODO. Better recognized voice filtration. recognized_voice = recognized_voice.to_lowercase(); @@ -108,6 +136,8 @@ fn main_loop() -> Result<(), ()> { start = SystemTime::now(); silence_frames = 0; stt::reset_speech_recognizer(); + + ipc::send(IpcEvent::Listening); continue 'voice_recognition; } @@ -143,6 +173,12 @@ fn main_loop() -> Result<(), ()> { // success info!("Command executed successfully."); + // notify GUI + ipc::send(IpcEvent::CommandExecuted { + id: cmd_config.id.clone(), + success: true, + }); + if chain { // chain commands start = SystemTime::now(); @@ -158,6 +194,14 @@ fn main_loop() -> Result<(), ()> { Err(msg) => { // fail error!("Error executing command: {}", msg); + + ipc::send(IpcEvent::CommandExecuted { + id: cmd_config.id.clone(), + success: false, + }); + ipc::send(IpcEvent::Error { + message: msg.to_string(), + }); } } } @@ -178,12 +222,17 @@ fn main_loop() -> Result<(), ()> { // reset things stt::reset_wake_recognizer(); audio_processing::reset(); + ipc::send(IpcEvent::Idle); } } None => (), } } + // cleanup + recorder::stop_recording().ok(); + ipc::send(IpcEvent::Stopping); + Ok(()) } @@ -191,5 +240,6 @@ fn keyword_callback(keyword_index: i32) {} pub fn close(code: i32) { info!("Closing application."); + ipc::send(IpcEvent::Stopping); std::process::exit(code); } diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 0a9f814..06c388b 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -1,9 +1,11 @@ use parking_lot::RwLock; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; // include core use jarvis_core::{ audio, audio_processing, commands, config, db, listener, recorder, stt, intent, + ipc::{self, IpcAction}, APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB, }; @@ -20,6 +22,8 @@ mod app; #[cfg(not(target_os = "macos"))] mod tray; +static SHOULD_STOP: AtomicBool = AtomicBool::new(false); + fn main() -> Result<(), String> { // initialize directories config::init_dirs()?; @@ -96,6 +100,34 @@ fn main() -> Result<(), String> { warn!("Audio processing init failed: {}", e); } + // init IPC + info!("Initializing IPC..."); + ipc::init(); + + ipc::set_action_handler(|action| { + match action { + IpcAction::Stop => { + info!("Received stop command from GUI"); + SHOULD_STOP.store(true, Ordering::SeqCst); + } + IpcAction::ReloadCommands => { + info!("Received reload commands request"); + // TODO: implement reload + } + IpcAction::SetMuted { muted } => { + info!("Received mute request: {}", muted); + // TODO: implement mute + } + _ => {} + } + }); + + // start WebSocket server for ipc + std::thread::spawn(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for IPC"); + rt.block_on(ipc::start_server()); + }); + // start the app (in the background thread) std::thread::spawn(|| { let _ = app::start(); @@ -104,4 +136,8 @@ fn main() -> Result<(), String> { tray::init_blocking(); Ok(()) +} + +pub fn should_stop() -> bool { + SHOULD_STOP.load(Ordering::SeqCst) } \ No newline at end of file diff --git a/crates/jarvis-cli/Cargo.toml b/crates/jarvis-cli/Cargo.toml index 87073e1..64644a9 100644 --- a/crates/jarvis-cli/Cargo.toml +++ b/crates/jarvis-cli/Cargo.toml @@ -8,7 +8,7 @@ edition.workspace = true [dependencies] jarvis-core = { path = "../jarvis-core", default-features = false, features = ["intent"]} -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] } log.workspace = true env_logger = "0.11" parking_lot.workspace = true \ No newline at end of file diff --git a/crates/jarvis-core/Cargo.toml b/crates/jarvis-core/Cargo.toml index 6551a9f..cdc1ec0 100644 --- a/crates/jarvis-core/Cargo.toml +++ b/crates/jarvis-core/Cargo.toml @@ -24,6 +24,8 @@ parking_lot.workspace = true toml.workspace = true sha2.workspace = true nnnoiseless = { workspace = true, optional = true } +tokio-tungstenite = { workspace = true, optional = true } +futures-util = { workspace = true, optional = true } # pv_recorder = { workspace = true, optional = true } vosk = { version = "0.3.1", optional = true } @@ -34,5 +36,5 @@ tokio = { version = "1", features = ["sync"], optional = true } [features] default = ["jarvis_app"] -jarvis_app = ["vosk", "intent-classifier", "tokio", "nnnoiseless"] +jarvis_app = ["vosk", "intent-classifier", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util"] intent = ["intent-classifier", "tokio"] \ No newline at end of file diff --git a/crates/jarvis-core/src/ipc.rs b/crates/jarvis-core/src/ipc.rs new file mode 100644 index 0000000..11cac5c --- /dev/null +++ b/crates/jarvis-core/src/ipc.rs @@ -0,0 +1,5 @@ +mod events; +mod server; + +pub use events::{IpcAction, IpcEvent}; +pub use server::{init, send, set_action_handler, start_server, IPC_ADDR, IPC_PORT}; \ No newline at end of file diff --git a/crates/jarvis-core/src/ipc/events.rs b/crates/jarvis-core/src/ipc/events.rs new file mode 100644 index 0000000..e555b04 --- /dev/null +++ b/crates/jarvis-core/src/ipc/events.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; + +// Events sent from jarvis-app to GUI +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum IpcEvent { + // Wake word detected, starting to listen + WakeWordDetected, + + // Actively listening for command + Listening, + + // Speech recognized + SpeechRecognized { text: String }, + + // Command was executed + CommandExecuted { id: String, success: bool }, + + // Returned to idle state + Idle, + + // Error occurred + Error { message: String }, + + // App started + Started, + + // App is shutting down + Stopping, + + // Pong response + Pong, +} + +// Actions sent from GUI to jarvis-app +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum IpcAction { + // Request graceful shutdown + Stop, + + // Reload commands from disk + ReloadCommands, + + // Ping to check connection + Ping, + + // Mute/unmute listening + SetMuted { muted: bool }, +} \ No newline at end of file diff --git a/crates/jarvis-core/src/ipc/server.rs b/crates/jarvis-core/src/ipc/server.rs new file mode 100644 index 0000000..53d1f7f --- /dev/null +++ b/crates/jarvis-core/src/ipc/server.rs @@ -0,0 +1,190 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use futures_util::{SinkExt, StreamExt}; +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::broadcast; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +use super::events::{IpcAction, IpcEvent}; + +pub const IPC_PORT: u16 = 9712; +pub const IPC_ADDR: &str = "127.0.0.1"; + +static BROADCAST_TX: OnceCell> = OnceCell::new(); +static ACTION_HANDLER: OnceCell>>>> = OnceCell::new(); + +// Initialize the IPC broadcast channel +pub fn init() -> broadcast::Sender { + if let Some(tx) = BROADCAST_TX.get() { + return tx.clone(); + } + + let (tx, _) = broadcast::channel::(32); + BROADCAST_TX.set(tx.clone()).ok(); + ACTION_HANDLER.set(Arc::new(RwLock::new(None))).ok(); + + info!("IPC: Broadcast channel initialized"); + tx +} + +// Send event to all connected clients +pub fn send(event: IpcEvent) { + if let Some(tx) = BROADCAST_TX.get() { + match tx.send(event.clone()) { + Ok(n) => { + if n > 0 { + debug!("IPC: Sent {:?} to {} client(s)", event, n); + } + } + Err(_) => { + // no receivers, that's fine + } + } + } +} + +// Register handler for incoming actions from GUI +pub fn set_action_handler(handler: F) +where + F: Fn(IpcAction) + Send + Sync + 'static, +{ + if let Some(h) = ACTION_HANDLER.get() { + *h.write() = Some(Box::new(handler)); + } +} + +fn handle_action(action: IpcAction) { + info!("IPC: Received action {:?}", action); + + // handle ping internally + if matches!(action, IpcAction::Ping) { + send(IpcEvent::Pong); + return; + } + + // forward to registered handler + if let Some(handler_lock) = ACTION_HANDLER.get() { + let handler = handler_lock.read(); + if let Some(ref h) = *handler { + h(action); + } + } +} + +// Start the WebSocket server (blocking) +pub async fn start_server() { + let addr = format!("{}:{}", IPC_ADDR, IPC_PORT); + let socket_addr: SocketAddr = addr.parse().expect("Invalid IPC address"); + + let listener = match TcpListener::bind(&socket_addr).await { + Ok(l) => { + info!("IPC: WebSocket server listening on ws://{}", addr); + l + } + Err(e) => { + error!("IPC: Failed to bind to {}: {}", addr, e); + return; + } + }; + + // notify that we're ready + send(IpcEvent::Started); + + while let Ok((stream, peer_addr)) = listener.accept().await { + info!("IPC: Client connecting from {}", peer_addr); + + let rx = BROADCAST_TX + .get() + .map(|tx| tx.subscribe()) + .expect("IPC not initialized"); + + tokio::spawn(handle_client(stream, peer_addr, rx)); + } +} + +async fn handle_client( + stream: TcpStream, + peer_addr: SocketAddr, + mut event_rx: broadcast::Receiver, +) { + let ws_stream = match accept_async(stream).await { + Ok(ws) => { + info!("IPC: Client connected: {}", peer_addr); + ws + } + Err(e) => { + error!("IPC: WebSocket handshake failed for {}: {}", peer_addr, e); + return; + } + }; + + let (mut ws_tx, mut ws_rx) = ws_stream.split(); + + loop { + tokio::select! { + // forward events to client + event_result = event_rx.recv() => { + match event_result { + Ok(event) => { + let json = match serde_json::to_string(&event) { + Ok(j) => j, + Err(e) => { + error!("IPC: Failed to serialize event: {}", e); + continue; + } + }; + + if ws_tx.send(Message::Text(json.into())).await.is_err() { + info!("IPC: Client {} disconnected (send failed)", peer_addr); + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("IPC: Client {} lagged {} events", peer_addr, n); + } + Err(broadcast::error::RecvError::Closed) => { + info!("IPC: Broadcast channel closed"); + break; + } + } + } + + // receive messages from client + msg_result = ws_rx.next() => { + match msg_result { + Some(Ok(Message::Text(text))) => { + match serde_json::from_str::(&text) { + Ok(action) => handle_action(action), + Err(e) => { + warn!("IPC: Invalid action from {}: {} ({})", peer_addr, text, e); + } + } + } + Some(Ok(Message::Ping(data))) => { + if ws_tx.send(Message::Pong(data)).await.is_err() { + break; + } + } + Some(Ok(Message::Close(_))) => { + info!("IPC: Client {} sent close frame", peer_addr); + break; + } + Some(Err(e)) => { + error!("IPC: Error receiving from {}: {}", peer_addr, e); + break; + } + None => { + info!("IPC: Client {} stream ended", peer_addr); + break; + } + _ => {} + } + } + } + } + + info!("IPC: Client disconnected: {}", peer_addr); +} \ No newline at end of file diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index b61eb08..d258e16 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -28,6 +28,9 @@ pub mod vosk_models; #[cfg(feature = "jarvis_app")] pub mod audio_processing; +#[cfg(feature = "jarvis_app")] +pub mod ipc; + // shared statics // pub static APP_DIR: Lazy = Lazy::new(|| std::env::current_dir().unwrap()); pub static APP_DIR: Lazy = Lazy::new(|| { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index f99b1cd..a35a359 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -5,21 +5,30 @@ import { SvelteUIProvider } from "@svelteuidev/core" import Events from "./Events.svelte" - import { - loadVoiceSetting, - loadAppInfo, - startStatsPolling, - stopStatsPolling + import { + loadVoiceSetting, + loadAppInfo, + startStatsPolling, + stopStatsPolling, + connectIpc, + disconnectIpc } from "@/stores" onMount(() => { + // load static data loadVoiceSetting() loadAppInfo() + + // start process monitoring startStatsPolling(5000) + + // connect to IPC + connectIpc() }) onDestroy(() => { stopStatsPolling() + disconnectIpc() }) diff --git a/frontend/src/components/elements/ArcReactor.svelte b/frontend/src/components/elements/ArcReactor.svelte index c7003ff..2bf2780 100644 --- a/frontend/src/components/elements/ArcReactor.svelte +++ b/frontend/src/components/elements/ArcReactor.svelte @@ -1,31 +1,68 @@ + + -
-
-
    - {#each Array(60) as _, i} -
  • - {/each} -
-
-
-
-
-
+
+
+
+
    + {#each Array(60) as _, i} +
  • + {/each} +
+
+
+
+
+
+
+
+
+
+
+
+ {#each Array(8) as _, i} +
+ {/each} +
-
-
-
-
-
- {#each Array(8) as _, i} -
- {/each} + +
+ + + {#if $jarvisState === "disconnected"} + Отключен + {:else if $jarvisState === "idle"} + Ожидание + {:else if $jarvisState === "listening"} + Слушаю + {:else if $jarvisState === "processing"} + Обработка + {/if} +
@@ -39,6 +76,24 @@ $colour1: rgba(2, 255, 255, 0.15); $colour3: rgba(2, 255, 255, 0.3); + // [ Wrapper ]-- + .arc-reactor-wrapper { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem 0; + } + + .state-label { + margin-top: 0.5rem; + font-size: 0.9rem; + color: #52fefe; + text-transform: uppercase; + letter-spacing: 2px; + opacity: 0.8; + transition: opacity 0.3s ease; + } + // [ Base container ]-- .reactor-container { width: 300px; @@ -62,6 +117,7 @@ width: 238px; background-color: #161a1b; box-shadow: 0px 0px 50px 15px $colour3, inset 0px 0px 50px 15px $colour3; + transition: box-shadow 0.5s ease; } // [ Utility classes ]-- @@ -85,6 +141,7 @@ border: 5px solid #1b4e5f; background-color: #ffffff; box-shadow: 0px 0px 7px 5px #52fefe, 0px 0px 10px 10px #52fefe inset; + transition: box-shadow 0.5s ease; } .core-outer { @@ -93,6 +150,7 @@ border: 1px solid #52fefe; background-color: #ffffff; box-shadow: 0px 0px 2px 1px #52fefe, 0px 0px 10px 5px #52fefe inset; + transition: box-shadow 0.5s ease; } .core-wrapper { @@ -100,6 +158,7 @@ height: 180px; background-color: #073c4b; box-shadow: 0px 0px 5px 4px #52fefe, 0px 0px 6px 2px #52fefe inset; + transition: box-shadow 0.5s ease; } .tunnel { @@ -107,6 +166,7 @@ height: 220px; background-color: #ffffff; box-shadow: 0px 0px 5px 1px #52fefe, 0px 0px 5px 4px #52fefe inset; + transition: box-shadow 0.5s ease; } // [ Coil animation ]-- @@ -115,7 +175,7 @@ width: 100%; height: 100%; animation: 10s infinite linear reactor-anim; - transition: animation 1s; + transition: animation-duration 0.5s ease; } .coil { @@ -155,7 +215,7 @@ background: transparent; border-radius: 50%; transform: rotateZ(0deg); - transition: box-shadow 3s ease; + transition: box-shadow 3s ease, opacity 0.5s ease; text-align: center; opacity: 0.3; } @@ -204,29 +264,148 @@ 100% { background: $marks-color-1; } } - // generate mark rotations @for $i from 1 through 60 { .marks li:nth-child(#{$i}) { transform: rotate(#{$i * 6}deg) translateY($arc-radius); } } - // [ Active state ]-- - .reactor-container.active { - scale: 1.1; - opacity: 1; + // [ DISCONNECTED state ]-- + .reactor-container.disconnected { + transform: scale(0.8); + opacity: 0.4; + filter: grayscale(0.8) brightness(0.6); .coil-container { - animation: 3s infinite linear reactor-anim; + animation-duration: 20s; + } + + .state-label { + opacity: 0.5; + } + } + + // [ IDLE state ]-- + .reactor-container.idle { + transform: scale(0.9); + opacity: 0.9; + + .coil-container { + animation-duration: 10s; } .reactor-container-inner { box-shadow: 0px 0px 50px 15px $colour3, inset 0px 0px 50px 15px $colour3; } + } + + // [ ACTIVE state (listening/processing) ]-- + .reactor-container.active { + transform: scale(1); + opacity: 1; + + .coil-container { + animation-duration: 3s; + } + + .reactor-container-inner { + box-shadow: 0px 0px 70px 25px $colour3, inset 0px 0px 70px 25px $colour3; + } + + .core-inner { + box-shadow: 0px 0px 15px 10px #52fefe, 0px 0px 20px 15px #52fefe inset; + } + + .core-outer { + box-shadow: 0px 0px 5px 3px #52fefe, 0px 0px 15px 10px #52fefe inset; + } + + .core-wrapper { + box-shadow: 0px 0px 10px 8px #52fefe, 0px 0px 10px 5px #52fefe inset; + } + + .tunnel { + box-shadow: 0px 0px 10px 3px #52fefe, 0px 0px 10px 8px #52fefe inset; + } + + .e7 { + opacity: 0.6; + } .e5_1 { animation: rotate 3s linear infinite; } .e5_2 { animation: rotate_anti 2s linear infinite; } .e5_3 { animation: rotate 2s linear infinite; } .e5_4 { animation: rotate_anti 2s linear infinite; } + + .marks li { + animation: colour_ease2_active 1s infinite ease-in-out; + } } - + + @keyframes colour_ease2_active { + 0% { background: $marks-color-1; box-shadow: 0 0 5px $marks-color-1; } + 50% { background: $marks-color-2; box-shadow: none; } + 100% { background: $marks-color-1; box-shadow: 0 0 5px $marks-color-1; } + } + + // [ Pulse animation for listening ]-- + @keyframes listening-pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.03); + } + } + + + // [ State Label ] + + .state-label { + margin-top: 1.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #52fefe; + box-shadow: 0 0 8px #52fefe; + } + + .label-text { + font-size: 0.9rem; + color: rgba(82, 254, 254, 0.8); + text-transform: uppercase; + letter-spacing: 3px; + font-weight: 400; + font-family: "Roboto Condensed", sans-serif; // "Trebuchet MS", Arial, Helvetica, sans-serif + } + + .reactor-container.active + .state-label { + .status-dot { + animation: dot-pulse 0.8s ease-in-out infinite; + } + .label-text { + color: #52fefe; + } + } + + .reactor-container.disconnected + .state-label { + .status-dot { + background: #445555; + box-shadow: none; + } + .label-text { + color: #445555; + } + } + + @keyframes dot-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.5); opacity: 0.7; } + } + \ No newline at end of file diff --git a/frontend/src/components/elements/_ArcReactor.svelte b/frontend/src/components/elements/_ArcReactor.svelte new file mode 100644 index 0000000..c7003ff --- /dev/null +++ b/frontend/src/components/elements/_ArcReactor.svelte @@ -0,0 +1,232 @@ + + + +
+
+
    + {#each Array(60) as _, i} +
  • + {/each} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each Array(8) as _, i} +
+ {/each} +
+
+ + diff --git a/frontend/src/lib/ipc.ts b/frontend/src/lib/ipc.ts new file mode 100644 index 0000000..6ece44e --- /dev/null +++ b/frontend/src/lib/ipc.ts @@ -0,0 +1,174 @@ +import { writable, get } from "svelte/store" + +// ### IPC STORES ### + +export type JarvisState = "disconnected" | "idle" | "listening" | "processing" + +export const jarvisState = writable("disconnected") +export const ipcConnected = writable(false) +export const lastRecognizedText = writable("") +export const lastExecutedCommand = writable("") +export const lastError = writable("") + +// ### CONNECTION ### + +const IPC_URL = "ws://127.0.0.1:9712" +const RECONNECT_DELAY = 5000 + +let ws: WebSocket | null = null +let reconnectTimer: ReturnType | null = null +let manualDisconnect = false +let enabled = false // only connect when enabled + +export function enableIpc() { + enabled = true + connectIpc() +} + +export function disableIpc() { + enabled = false + disconnectIpc() +} + +export function connectIpc() { + if (!enabled) { + console.log("IPC: Not enabled, skipping connection") + return + } + + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) { + return + } + + manualDisconnect = false + + console.log("IPC: Connecting to", IPC_URL) + + try { + ws = new WebSocket(IPC_URL) + + ws.onopen = () => { + console.log("IPC: Connected") + ipcConnected.set(true) + jarvisState.set("idle") + sendAction("ping") + } + + ws.onclose = (event) => { + console.log("IPC: Disconnected", event.code) + ipcConnected.set(false) + jarvisState.set("disconnected") + ws = null + + if (!manualDisconnect && enabled) { + scheduleReconnect() + } + } + + ws.onerror = () => { + // error is handled in onclose, just suppress console spam + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + handleEvent(data) + } catch (e) { + console.error("IPC: Failed to parse message:", event.data, e) + } + } + } catch (e) { + // suppress errors when server isn't running + if (enabled) { + scheduleReconnect() + } + } +} + +function scheduleReconnect() { + if (reconnectTimer || manualDisconnect || !enabled) return + + console.log(`IPC: Will retry in ${RECONNECT_DELAY / 1000}s...`) + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connectIpc() + }, RECONNECT_DELAY) +} + +export function disconnectIpc() { + manualDisconnect = true + + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + if (ws) { + ws.close() + ws = null + } + + ipcConnected.set(false) + jarvisState.set("disconnected") +} + +// ### EVENT HANDLING ### + +function handleEvent(data: any) { + console.log("IPC: Event", data.event, data) + + switch (data.event) { + case "wake_word_detected": + case "listening": + jarvisState.set("listening") + break + + case "speech_recognized": + lastRecognizedText.set(data.text || "") + jarvisState.set("processing") + break + + case "command_executed": + lastExecutedCommand.set(data.id || "") + break + + case "idle": + jarvisState.set("idle") + break + + case "error": + lastError.set(data.message || "Unknown error") + break + + case "started": + jarvisState.set("idle") + break + + case "stopping": + jarvisState.set("disconnected") + break + + case "pong": + // connection verified + break + } +} + +// ### ACTIONS ### + +export function sendAction(action: string, payload: Record = {}) { + if (ws?.readyState !== WebSocket.OPEN) { + return false + } + + ws.send(JSON.stringify({ action, ...payload })) + return true +} + +export function stopJarvisApp() { + return sendAction("stop") +} + +export function reloadCommands() { + return sendAction("reload_commands") +} \ No newline at end of file diff --git a/frontend/src/routes/index.svelte b/frontend/src/routes/index.svelte index 9801eb5..f902808 100644 --- a/frontend/src/routes/index.svelte +++ b/frontend/src/routes/index.svelte @@ -4,38 +4,56 @@ import { Notification, Space, Button } from "@svelteuidev/core" import { InfoCircled } from "radix-icons-svelte" - import SearchBar from "@/components/elements/SearchBar.svelte" import ArcReactor from "@/components/elements/ArcReactor.svelte" import HDivider from "@/components/elements/HDivider.svelte" import Stats from "@/components/elements/Stats.svelte" import Footer from "@/components/Footer.svelte" - import { isJarvisRunning, updateJarvisStats } from "@/stores" + import { + isJarvisRunning, + updateJarvisStats, + jarvisState, + ipcConnected, + enableIpc, + disableIpc + } from "@/stores" - let running = false + let processRunning = false let launching = false - isJarvisRunning.subscribe(value => { - running = value + // when process state changes, enable/disable IPC + isJarvisRunning.subscribe((value) => { + processRunning = value + + if (value) { + // process is running, enable IPC connection + enableIpc() + } else { + // process stopped, disable IPC (stops reconnect attempts) + disableIpc() + } }) onMount(() => { document.body.classList.add("assist-page") + updateJarvisStats() }) onDestroy(() => { document.body.classList.remove("assist-page") + disableIpc() }) async function runAssistant() { launching = true try { await invoke("run_jarvis_app") - // wait a bit then check if it's running - setTimeout(() => { - updateJarvisStats() + + // wait for startup + setTimeout(async () => { + await updateJarvisStats() launching = false - }, 2000) + }, 2500) } catch (err) { console.error("Failed to run jarvis-app:", err) launching = false @@ -45,7 +63,7 @@ -{#if !running} +{#if !processRunning} {:else} + + {#if !$ipcConnected} + + Устанавливается связь с ассистентом... + + {/if} {/if} diff --git a/frontend/src/stores.ts b/frontend/src/stores.ts index 51bf3a6..4df7d29 100644 --- a/frontend/src/stores.ts +++ b/frontend/src/stores.ts @@ -1,6 +1,22 @@ import { writable } from "svelte/store" import { invoke } from "@tauri-apps/api/core" +// ### RE-EXPORT IPC STORES +export { + jarvisState, + ipcConnected, + lastRecognizedText, + lastExecutedCommand, + lastError, + connectIpc, + enableIpc, + disableIpc, + disconnectIpc, + sendAction, + stopJarvisApp, + reloadCommands +} from "./lib/ipc" + // ### RUNNING STATE export const isJarvisRunning = writable(false) export const jarvisRamUsage = writable(0)