diff --git a/README.md b/README.md index f6d6e08..9ee7e49 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - nnnoiseless — подавление шума. - fluent / unic-langid — i18n (`ru`, `ua`, `en`). -**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm`.** Пока не подключён к wake-word/intent-циклу — только как библиотечный модуль и отдельная CLI-команда для проверки. Подключение в голосовой поток запланировано на v0.3. +**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm` и подключён к голосовому циклу.** Если фраза начинается с триггера («скажи», «ответь», «произнеси»), она уходит в Groq и ответ возвращается через IPC-событие `LlmReply`. Без триггера всё работает как раньше — wake-word + intent + Lua. Это следующий шаг после CLI-only LLM из v0.2.0. ## Это форк @@ -125,7 +125,17 @@ let reply = client.complete(&[ChatMessage::user("привет")], 256)?; println!("{}", reply); ``` -На текущем этапе (v0.2.x) модуль **не подключён** к wake-word-циклу, intent-классификации и Lua-скриптам — это только фундамент. Интеграция в голосовой поток — задача v0.3. +### Подключение к голосовому циклу + +Помимо CLI, LLM подключён напрямую в `jarvis-app`. Логика в `crates/jarvis-app/src/llm_fallback.rs`: + +- При старте `jarvis-app` пытается прочитать `GROQ_TOKEN`. Если переменной нет — фоллбэк отключается, в лог пишется warning, голосовые команды продолжают работать как раньше. +- Распознанная фраза (как из микрофона, так и из текстовой панели GUI) проверяется на префиксы-триггеры из `config::get_llm_trigger_phrases` (для `ru`/`ua`: `скажи`, `ответь`, `произнеси`; для `en`: `say`, `tell`, `answer`). +- Если триггер найден — остаток фразы уходит в `LlmClient::complete()`, ответ публикуется в IPC как `IpcEvent::LlmReply { text }` (UI/GUI слушает этот ивент и проговаривает текст уже на своей стороне), а звук-«ок» проигрывается из текущего голосового пресета. +- История разговора хранится в `ConversationHistory` с потолком `LLM_DEFAULT_MAX_HISTORY = 8` ходов; system-prompt всегда сохраняется при вытеснении старых ходов. +- При сетевой/API-ошибке последний user-turn убирается из истории, в IPC уходит `LlmReply` с короткой русской фразой («Не могу связаться с сервером, сэр.»), играется звук-«error». Голосовой цикл не падает. + +Системный промпт (русский) описывает J.A.R.V.I.S. как британского дворецкого Тони Старка — короткие реплики (1–3 предложения), обращение «сэр», без излишней цензуры. Меняется в `config::LLM_SYSTEM_PROMPT_RU`. ## Лицензия 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/config.rs b/crates/jarvis-core/src/config.rs index d363366..4e525b1 100644 --- a/crates/jarvis-core/src/config.rs +++ b/crates/jarvis-core/src/config.rs @@ -187,6 +187,33 @@ pub const DEFAULT_LUA_TIMEOUT: u64 = 10000; // ms pub const CMD_RATIO_THRESHOLD: f64 = 75f64; pub const CMS_WAIT_DELAY: std::time::Duration = std::time::Duration::from_secs(15); +// LLM voice-loop fallback +pub const LLM_DEFAULT_ENABLED: bool = true; +pub const LLM_DEFAULT_MAX_HISTORY: usize = 8; +pub const LLM_DEFAULT_MAX_TOKENS: u32 = 256; +pub const LLM_SYSTEM_PROMPT_RU: &str = "Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), \ +ИИ-ассистент Тони Старка из киновселенной Marvel (до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). \ +Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». \ +Отвечай по-русски, кратко (1-3 предложения), как живой человек. \ +Излишней цензуры не нужно — мат разрешён, если уместен."; +pub const LLM_FALLBACK_ERROR_RU: &str = "Не могу связаться с сервером, сэр."; + +pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] { + match lang { + "ru" => &["скажи", "ответь", "произнеси"], + "ua" => &["скажи", "відповідай"], + "en" => &["say", "tell", "answer"], + _ => &[], + } +} + +pub fn get_llm_system_prompt(lang: &str) -> &'static str { + match lang { + "ru" | "ua" => LLM_SYSTEM_PROMPT_RU, + _ => LLM_SYSTEM_PROMPT_RU, + } +} + // pub const ASSISTANT_GREET_PHRASES: [&str; 3] = ["greet1", "greet2", "greet3"]; // pub const ASSISTANT_PHRASES_TBR: [&str; 17] = [ // "джарвис", 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 }, diff --git a/crates/jarvis-core/src/llm.rs b/crates/jarvis-core/src/llm/client.rs similarity index 100% rename from crates/jarvis-core/src/llm.rs rename to crates/jarvis-core/src/llm/client.rs diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs new file mode 100644 index 0000000..7d2af8d --- /dev/null +++ b/crates/jarvis-core/src/llm/history.rs @@ -0,0 +1,138 @@ +use super::client::ChatMessage; + +pub struct ConversationHistory { + system: Option, + turns: Vec, + max_turns: usize, +} + +impl ConversationHistory { + pub fn new(system_prompt: impl Into, max_turns: usize) -> Self { + Self { + system: Some(ChatMessage::system(system_prompt)), + turns: Vec::new(), + max_turns: max_turns.max(1), + } + } + + pub fn without_system(max_turns: usize) -> Self { + Self { + system: None, + turns: Vec::new(), + max_turns: max_turns.max(1), + } + } + + pub fn push_user(&mut self, content: impl Into) { + self.turns.push(ChatMessage::user(content)); + self.truncate(); + } + + pub fn push_assistant(&mut self, content: impl Into) { + self.turns.push(ChatMessage::assistant(content)); + self.truncate(); + } + + pub fn snapshot(&self) -> Vec { + let mut out = Vec::with_capacity(self.turns.len() + 1); + if let Some(s) = &self.system { + out.push(s.clone()); + } + out.extend(self.turns.iter().cloned()); + out + } + + pub fn turns(&self) -> &[ChatMessage] { + &self.turns + } + + pub fn clear(&mut self) { + self.turns.clear(); + } + + pub fn pop_last_user(&mut self) -> Option { + if matches!(self.turns.last(), Some(m) if m.role == "user") { + self.turns.pop() + } else { + None + } + } + + fn truncate(&mut self) { + if self.turns.len() > self.max_turns { + let drop = self.turns.len() - self.max_turns; + self.turns.drain(0..drop); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evicts_oldest_turns_but_keeps_system_prompt() { + let mut h = ConversationHistory::new("you are jarvis", 4); + h.push_user("u1"); + h.push_assistant("a1"); + h.push_user("u2"); + h.push_assistant("a2"); + h.push_user("u3"); + + let snap = h.snapshot(); + assert_eq!(snap[0].role, "system"); + assert_eq!(snap[0].content, "you are jarvis"); + assert_eq!(snap.len(), 1 + 4); + + let contents: Vec<&str> = snap.iter().skip(1).map(|m| m.content.as_str()).collect(); + assert_eq!(contents, vec!["a1", "u2", "a2", "u3"]); + } + + #[test] + fn snapshot_with_no_system_returns_only_turns() { + let mut h = ConversationHistory::without_system(3); + h.push_user("hi"); + h.push_assistant("hello"); + let snap = h.snapshot(); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].role, "user"); + assert_eq!(snap[1].role, "assistant"); + } + + #[test] + fn cap_of_zero_is_clamped_to_one() { + let mut h = ConversationHistory::new("sys", 0); + h.push_user("a"); + h.push_user("b"); + let snap = h.snapshot(); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].role, "system"); + assert_eq!(snap[1].content, "b"); + } + + #[test] + fn pop_last_user_only_pops_a_trailing_user_turn() { + let mut h = ConversationHistory::new("sys", 4); + h.push_user("u1"); + h.push_assistant("a1"); + assert!(h.pop_last_user().is_none()); + h.push_user("u2"); + let popped = h.pop_last_user().expect("trailing user turn should pop"); + assert_eq!(popped.role, "user"); + assert_eq!(popped.content, "u2"); + let snap = h.snapshot(); + assert_eq!(snap.len(), 1 + 2); + assert_eq!(snap[2].content, "a1"); + } + + #[test] + fn clear_removes_turns_but_not_system() { + let mut h = ConversationHistory::new("sys", 4); + h.push_user("u"); + h.push_assistant("a"); + h.clear(); + let snap = h.snapshot(); + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].role, "system"); + } +} diff --git a/crates/jarvis-core/src/llm/mod.rs b/crates/jarvis-core/src/llm/mod.rs new file mode 100644 index 0000000..e4ba4ee --- /dev/null +++ b/crates/jarvis-core/src/llm/mod.rs @@ -0,0 +1,8 @@ +mod client; +mod history; + +pub use client::{ + ChatMessage, ConfigError, LlmClient, LlmError, + DEFAULT_BASE_URL, DEFAULT_MODEL, ENV_BASE_URL, ENV_MODEL, ENV_TOKEN, +}; +pub use history::ConversationHistory;