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::long_term_memory; use jarvis_core::profiles; use jarvis_core::tts::{self, SpeakOpts}; 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 or run Ollama 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 (backend: {}, model: {}).", client.backend().name(), 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); let mut snap = h.snapshot(); // Inject (a) profile personality (b) relevant long-term memory as a fresh // system message right after the base prompt. Both are optional. let profile = profiles::active(); let mut overlay = String::new(); if !profile.llm_personality.is_empty() { overlay.push_str(&format!("Активный профиль: {} {}\nХарактер для ответа: {}\n", profile.icon, profile.name, profile.llm_personality)); } let mem_ctx = long_term_memory::build_context(prompt, 5); if !mem_ctx.is_empty() { overlay.push_str(&mem_ctx); } if !overlay.is_empty() { // Insert after the base system prompt (index 0 typically), before user msgs. let insert_at = if !snap.is_empty() && snap[0].role == "system" { 1 } else { 0 }; snap.insert(insert_at, ChatMessage::system(overlay)); } snap }; 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_reply(&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_reply(&err_text); } } } fn speak_reply(text: &str) { if !jarvis_core::runtime_config::llm_tts_enabled() { return; } tts::speak(text, &SpeakOpts::lang("ru")); }