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.clone() }); voices::play_ok(); speak_via_sapi(&reply); } Err(e) => { error!("LLM request failed: {}", e); state.history.lock().pop_last_user(); let err_text = config::LLM_FALLBACK_ERROR_RU.to_string(); ipc::send(IpcEvent::LlmReply { text: err_text.clone() }); voices::play_error(); speak_via_sapi(&err_text); } } } #[cfg(target_os = "windows")] fn speak_via_sapi(text: &str) { if std::env::var("JARVIS_LLM_TTS").as_deref() == Ok("false") { return; } let cleaned = jarvis_core::text_utils::sanitize_for_speech(text); let escaped = cleaned.replace('\'', "''"); let ps = format!( "Add-Type -AssemblyName System.Speech; \ $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; \ foreach ($v in $s.GetInstalledVoices()) {{ \ if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') {{ \ try {{ $s.SelectVoice($v.VoiceInfo.Name); break }} catch {{ }} \ }} \ }} \ $s.Speak('{}')", escaped, ); match std::process::Command::new("powershell") .args(["-NoProfile", "-Command", &ps]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() { Ok(_) => {} Err(e) => warn!("SAPI spawn failed: {}", e), } } #[cfg(not(target_os = "windows"))] fn speak_via_sapi(_text: &str) { // No-op on non-Windows; LLM reply still arrives via IPC for the GUI to render. }