From e5aaa7e064d20b2e46533e7594befe8ec055c38a Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:16:02 +0300 Subject: [PATCH] feat(app): wire LlmClient into voice loop with trigger-prefix fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new module crates/jarvis-app/src/llm_fallback.rs holds an optional LlmClient + ConversationHistory built once at startup. if GROQ_TOKEN is unset the module logs a warning and stays disabled — voice commands keep working as before. both the wake-word voice path (recognize_command in app.rs) and the gui-side text command path (process_text_command) now check whether the recognized phrase starts with one of the configured trigger phrases (ru: 'скажи' / 'ответь' / 'произнеси'). when it does, the remainder of the phrase is sent to Groq and the reply is published as a new IpcEvent::LlmReply { text } so the gui can speak it. on api error the trailing user turn is popped from history, the russian fallback line is sent over ipc and a 'error' voice cue plays. the loop itself never panics. --- crates/jarvis-app/src/app.rs | 27 +++++- crates/jarvis-app/src/llm_fallback.rs | 113 ++++++++++++++++++++++++++ crates/jarvis-app/src/main.rs | 4 + crates/jarvis-core/src/ipc/events.rs | 3 + 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 crates/jarvis-app/src/llm_fallback.rs diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index cbc900f..3ead27e 100644 --- a/crates/jarvis-app/src/app.rs +++ b/crates/jarvis-app/src/app.rs @@ -250,7 +250,20 @@ fn recognize_command( } first_recognition = false; - + + if crate::llm_fallback::is_enabled() { + if let Some(prompt) = crate::llm_fallback::extract_prompt(&recognized_voice) { + crate::llm_fallback::handle(&prompt); + stt::reset_speech_recognizer(); + vad_state = VadState::WaitingForVoice; + silence_frames = 0; + start = SystemTime::now(); + audio_buffer.clear(); + ipc::send(IpcEvent::Listening); + continue; + } + } + // filter activation phrases // for tbr in config::ASSISTANT_PHRASES_TBR { // recognized_voice = recognized_voice.replace(tbr, ""); @@ -317,9 +330,17 @@ fn recognize_command( fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) { info!("Processing text command: {}", text); - + ipc::send(IpcEvent::SpeechRecognized { text: text.to_string() }); - + + if crate::llm_fallback::is_enabled() { + if let Some(prompt) = crate::llm_fallback::extract_prompt(text) { + crate::llm_fallback::handle(&prompt); + ipc::send(IpcEvent::Idle); + return; + } + } + let mut filtered = text.to_lowercase(); // for tbr in config::ASSISTANT_PHRASES_TBR { // filtered = filtered.replace(tbr, ""); diff --git a/crates/jarvis-app/src/llm_fallback.rs b/crates/jarvis-app/src/llm_fallback.rs new file mode 100644 index 0000000..4d8a9f6 --- /dev/null +++ b/crates/jarvis-app/src/llm_fallback.rs @@ -0,0 +1,113 @@ +use once_cell::sync::OnceCell; +use parking_lot::Mutex; + +use jarvis_core::config; +use jarvis_core::i18n; +use jarvis_core::ipc::{self, IpcEvent}; +use jarvis_core::llm::{ChatMessage, ConversationHistory, LlmClient}; +use jarvis_core::voices; + +struct State { + client: LlmClient, + history: Mutex, + max_tokens: u32, +} + +static STATE: OnceCell> = OnceCell::new(); + +pub fn init() { + let _ = STATE.set(build_state()); +} + +fn build_state() -> Option { + if !config::LLM_DEFAULT_ENABLED { + info!("LLM fallback disabled by config."); + return None; + } + + let client = match LlmClient::from_env() { + Ok(c) => c, + Err(e) => { + warn!("LLM fallback disabled: {}. Set GROQ_TOKEN to enable.", e); + return None; + } + }; + + let lang = i18n::get_language(); + let prompt = config::get_llm_system_prompt(&lang); + let history = Mutex::new(ConversationHistory::new(prompt, config::LLM_DEFAULT_MAX_HISTORY)); + + info!("LLM fallback enabled (model: {}).", client.model()); + Some(State { + client, + history, + max_tokens: config::LLM_DEFAULT_MAX_TOKENS, + }) +} + +pub fn is_enabled() -> bool { + STATE.get().and_then(|s| s.as_ref()).is_some() +} + +pub fn extract_prompt(text: &str) -> Option { + let lang = i18n::get_language(); + let triggers = config::get_llm_trigger_phrases(&lang); + let lowered = text.to_lowercase(); + + for trig in triggers { + if let Some(rest) = strip_trigger(&lowered, trig) { + let trimmed = rest.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn strip_trigger(text: &str, trigger: &str) -> Option { + let t = text.trim_start(); + if let Some(after) = t.strip_prefix(trigger) { + let next = after.chars().next(); + if next.map_or(true, |c| !c.is_alphanumeric()) { + return Some(after.to_string()); + } + } + None +} + +pub fn handle(prompt: &str) { + let state = match STATE.get().and_then(|s| s.as_ref()) { + Some(s) => s, + None => { + warn!("LLM fallback called while disabled — ignoring."); + return; + } + }; + + info!("LLM prompt: {}", prompt); + + let snapshot: Vec = { + let mut h = state.history.lock(); + h.push_user(prompt); + h.snapshot() + }; + + match state.client.complete(&snapshot, state.max_tokens) { + Ok(reply) => { + let reply = reply.trim().to_string(); + info!("LLM reply: {}", reply); + state.history.lock().push_assistant(reply.clone()); + ipc::send(IpcEvent::LlmReply { text: reply }); + voices::play_ok(); + } + Err(e) => { + error!("LLM request failed: {}", e); + state.history.lock().pop_last_user(); + ipc::send(IpcEvent::LlmReply { + text: config::LLM_FALLBACK_ERROR_RU.to_string(), + }); + voices::play_error(); + } + } +} diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 7a40397..f704a70 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -18,6 +18,7 @@ mod log; // include app mod app; +mod llm_fallback; // include tray // @TODO. macOS currently not supported for tray functionality. @@ -55,6 +56,9 @@ fn main() -> Result<(), String> { // init i18n i18n::init(&settings.lock().language); + // init LLM fallback (no-op if GROQ_TOKEN missing) + llm_fallback::init(); + // init recorder if recorder::init().is_err() { app::close(1); diff --git a/crates/jarvis-core/src/ipc/events.rs b/crates/jarvis-core/src/ipc/events.rs index e372d40..7f76f72 100644 --- a/crates/jarvis-core/src/ipc/events.rs +++ b/crates/jarvis-core/src/ipc/events.rs @@ -12,6 +12,9 @@ pub enum IpcEvent { // Speech recognized SpeechRecognized { text: String }, + + // LLM produced a reply for a free-form question + LlmReply { text: String }, // Command was executed CommandExecuted { id: String, success: bool },