diff --git a/crates/jarvis-app/src/llm_fallback.rs b/crates/jarvis-app/src/llm_fallback.rs index 086037b..bd65389 100644 --- a/crates/jarvis-app/src/llm_fallback.rs +++ b/crates/jarvis-app/src/llm_fallback.rs @@ -1,20 +1,19 @@ 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::{self, ChatMessage, ConversationHistory}; +use jarvis_core::llm::{self, ChatMessage}; use jarvis_core::long_term_memory; use jarvis_core::profiles; use jarvis_core::tts::{self, SpeakOpts}; use jarvis_core::voices; -// State now only holds conversation history — the actual LLM client lives in -// `jarvis_core::llm::GLOBAL` so that voice/Lua commands can hot-swap backends -// (Ollama ↔ Groq) without rebuilding this module's state. +// State now only holds the max_tokens config — both the LLM client AND the +// conversation history live in `jarvis_core::llm::*` globals. This lets voice +// commands ("сбрось контекст", "повтори последнее") and Lua scripts share the +// same buffer instead of each module owning its own. struct State { - history: Mutex, max_tokens: u32, } @@ -38,11 +37,10 @@ fn build_state() -> Option { 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)); + llm::init_history(prompt, config::LLM_DEFAULT_MAX_HISTORY); info!("LLM fallback enabled (backend: {}).", llm::current_backend_name()); Some(State { - history, max_tokens: config::LLM_DEFAULT_MAX_TOKENS, }) } @@ -89,31 +87,26 @@ pub fn handle(prompt: &str) { info!("LLM prompt: {}", prompt); - let snapshot: Vec = { - let mut h = state.history.lock(); - h.push_user(prompt); - let mut snap = h.snapshot(); + llm::history_push_user(prompt); + let mut snapshot: Vec = llm::history_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 - }; + // 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 !snapshot.is_empty() && snapshot[0].role == "system" { 1 } else { 0 }; + snapshot.insert(insert_at, ChatMessage::system(overlay)); + } let client = match llm::current() { Some(c) => c, @@ -127,14 +120,14 @@ pub fn handle(prompt: &str) { Ok(reply) => { let reply = reply.trim().to_string(); info!("LLM reply: {}", reply); - state.history.lock().push_assistant(reply.clone()); + llm::history_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(); + llm::history_pop_last_user(); let err_text = config::LLM_FALLBACK_ERROR_RU.to_string(); ipc::send(IpcEvent::LlmReply { text: err_text.clone() }); voices::play_error(); diff --git a/crates/jarvis-core/src/db/structs.rs b/crates/jarvis-core/src/db/structs.rs index dd63b0d..8e09e60 100644 --- a/crates/jarvis-core/src/db/structs.rs +++ b/crates/jarvis-core/src/db/structs.rs @@ -33,6 +33,19 @@ pub struct Settings { pub language: String, pub api_keys: ApiKeys, + + /// Preferred LLM backend ("groq" | "ollama" | empty=auto). Empty/auto means + /// the JARVIS_LLM env var or auto-detect (Groq if GROQ_TOKEN set else Ollama) + /// decides. When the user runs `jarvis.llm_switch(...)` the result is + /// persisted here so it survives restart. + #[serde(default)] + pub llm_backend: String, + + /// Preferred TTS backend ("sapi" | "piper" | "silero" | empty=auto). Same + /// rules as `llm_backend`. Empty falls back to JARVIS_TTS env var, then + /// auto-detect. + #[serde(default)] + pub tts_backend: String, } fn default_intent_backend() -> String { config::DEFAULT_INTENT_BACKEND.to_string() } @@ -60,6 +73,8 @@ impl Settings { "language" => Some(self.language.clone()), "api_key__picovoice" => Some(self.api_keys.picovoice.clone()), "api_key__openai" => Some(self.api_keys.openai.clone()), + "llm_backend" => Some(self.llm_backend.clone()), + "tts_backend" => Some(self.tts_backend.clone()), _ => None, } } @@ -120,6 +135,22 @@ impl Settings { "api_key__openai" => { self.api_keys.openai = val.to_string(); } + "llm_backend" => { + // empty / "auto" / "groq" / "ollama" — anything else rejected + match val.trim().to_lowercase().as_str() { + "" | "auto" => self.llm_backend = String::new(), + "groq" => self.llm_backend = "groq".into(), + "ollama" => self.llm_backend = "ollama".into(), + other => return Err(format!("unknown llm_backend: '{}'", other)), + } + } + "tts_backend" => { + match val.trim().to_lowercase().as_str() { + "" | "auto" => self.tts_backend = String::new(), + "sapi" | "piper" | "silero" => self.tts_backend = val.to_lowercase(), + other => return Err(format!("unknown tts_backend: '{}'", other)), + } + } _ => return Err(format!("unknown setting: '{}'", key)), } Ok(()) @@ -142,6 +173,8 @@ impl Settings { "language", "api_key__picovoice", "api_key__openai", + "llm_backend", + "tts_backend", ] } } @@ -173,6 +206,9 @@ impl Default for Settings { picovoice: String::from(""), openai: String::from(""), }, + + llm_backend: String::new(), + tts_backend: String::new(), } } } diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs index 7d2af8d..6607b96 100644 --- a/crates/jarvis-core/src/llm/history.rs +++ b/crates/jarvis-core/src/llm/history.rs @@ -58,6 +58,11 @@ impl ConversationHistory { } } + /// Return the most recent assistant message, or None if none yet. + pub fn last_assistant(&self) -> Option<&ChatMessage> { + self.turns.iter().rev().find(|m| m.role == "assistant") + } + fn truncate(&mut self) { if self.turns.len() > self.max_turns { let drop = self.turns.len() - self.max_turns; diff --git a/crates/jarvis-core/src/llm/mod.rs b/crates/jarvis-core/src/llm/mod.rs index 5ac2aa6..5755913 100644 --- a/crates/jarvis-core/src/llm/mod.rs +++ b/crates/jarvis-core/src/llm/mod.rs @@ -12,6 +12,52 @@ use once_cell::sync::Lazy; use parking_lot::RwLock; use std::sync::Arc; +// ─── Shared conversation history ─────────────────────────────────────────── +// +// Single global history so the LLM fallback chat, the Lua reset/repeat helpers, +// and any future IPC handler all operate on the same buffer. Initialised on +// boot via `init_history(system_prompt, max_turns)`. + +static HISTORY: Lazy>> = Lazy::new(|| RwLock::new(None)); + +pub fn init_history(system_prompt: impl Into, max_turns: usize) { + *HISTORY.write() = Some(ConversationHistory::new(system_prompt, max_turns)); +} + +pub fn history_push_user(content: impl Into) { + if let Some(h) = HISTORY.write().as_mut() { + h.push_user(content); + } +} + +pub fn history_push_assistant(content: impl Into) { + if let Some(h) = HISTORY.write().as_mut() { + h.push_assistant(content); + } +} + +pub fn history_snapshot() -> Vec { + HISTORY.read().as_ref().map(|h| h.snapshot()).unwrap_or_default() +} + +pub fn history_clear() { + if let Some(h) = HISTORY.write().as_mut() { + h.clear(); + } +} + +pub fn history_pop_last_user() { + if let Some(h) = HISTORY.write().as_mut() { + h.pop_last_user(); + } +} + +/// Return the most recent assistant message text, or None. +pub fn history_last_assistant() -> Option { + HISTORY.read().as_ref() + .and_then(|h| h.last_assistant().map(|m| m.content.clone())) +} + /// Shared mutable LLM client. Modules read via `current()`; the user can /// hot-swap backends at runtime via `swap_to(LlmBackend::Ollama)` etc. /// @@ -19,12 +65,28 @@ use std::sync::Arc; /// `init_global()` was never called). static GLOBAL: Lazy>>> = Lazy::new(|| RwLock::new(None)); -/// Initialise the global client from env vars (`JARVIS_LLM`, `GROQ_TOKEN`, ...). +/// Initialise the global client. Precedence: +/// 1. Settings DB `llm_backend` field (if non-empty) +/// 2. `JARVIS_LLM` env var +/// 3. Auto-detect (Groq if `GROQ_TOKEN`, else Ollama) +/// /// Idempotent — replaces any previous global. Returns Ok even when the chosen /// backend can't be probed: an Ollama server may not be running yet but we /// stash the client anyway and let calls fail on first request. pub fn init_global() -> Result<(), ConfigError> { - let c = LlmClient::from_env()?; + // Try DB-persisted choice first. + let persisted = crate::DB.get().and_then(|db| { + let s = db.read(); + let backend = s.llm_backend.trim().to_string(); + if backend.is_empty() { None } else { parse_backend(&backend) } + }); + + let c = match persisted { + Some(LlmBackend::Groq) => LlmClient::groq()?, + Some(LlmBackend::Ollama) => LlmClient::ollama(), + None => LlmClient::from_env()?, + }; + log::info!("LLM global initialised: backend={}, model={}", c.backend().name(), c.model()); *GLOBAL.write() = Some(Arc::new(c)); Ok(()) @@ -45,6 +107,7 @@ pub fn current_backend_name() -> &'static str { } /// Hot-swap to a different backend. Returns the new backend's name on success. +/// Persists the choice to the settings DB so it survives restart. pub fn swap_to(backend: LlmBackend) -> Result<&'static str, ConfigError> { let c = match backend { LlmBackend::Groq => LlmClient::groq()?, @@ -53,6 +116,21 @@ pub fn swap_to(backend: LlmBackend) -> Result<&'static str, ConfigError> { let name = c.backend().name(); log::info!("LLM swapped → backend={}, model={}", name, c.model()); *GLOBAL.write() = Some(Arc::new(c)); + + // Persist to settings DB (best-effort — log on failure, don't propagate). + if let Some(db) = crate::DB.get() { + let snapshot = { + let mut s = db.write(); + s.llm_backend = name.to_string(); + s.clone() + }; + if let Err(e) = crate::db::save_settings(&snapshot) { + log::warn!("LLM swap: failed to persist to settings DB: {}", e); + } else { + log::info!("LLM choice persisted: {}", name); + } + } + Ok(name) } diff --git a/crates/jarvis-core/src/lua/api.rs b/crates/jarvis-core/src/lua/api.rs index 4bbac74..22b0326 100644 --- a/crates/jarvis-core/src/lua/api.rs +++ b/crates/jarvis-core/src/lua/api.rs @@ -12,4 +12,5 @@ pub mod memory; pub mod profile; pub mod vision; pub mod scheduler; -pub mod cmd; \ No newline at end of file +pub mod cmd; +pub mod health; \ No newline at end of file diff --git a/crates/jarvis-core/src/lua/api/health.rs b/crates/jarvis-core/src/lua/api/health.rs new file mode 100644 index 0000000..9887531 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/health.rs @@ -0,0 +1,50 @@ +//! `jarvis.health()` — debug snapshot of active runtime state. +//! +//! Returns a table that can be JSON-encoded for bug reports. Doesn't include +//! secrets (no API keys, no LLM history content). +//! +//! Example use from Lua: +//! local h = jarvis.health() +//! for k, v in pairs(h) do print(k, v) end + +use mlua::{Lua, Table}; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let health_fn = lua.create_function(|lua, ()| { + let t = lua.create_table()?; + + // TTS + t.set("tts_backend", crate::tts::backend().name())?; + + // LLM + t.set("llm_backend", crate::llm::current_backend_name())?; + if let Some(c) = crate::llm::current() { + t.set("llm_model", c.model().to_string())?; + } + + // Profile + t.set("active_profile", crate::profiles::active_name())?; + + // Memory size + t.set("memory_facts", crate::long_term_memory::all().len())?; + + // Scheduler size + t.set("scheduled_tasks", crate::scheduler::list().len())?; + + // Language + t.set("language", crate::i18n::get_language())?; + + // Voice from settings + if let Some(db) = crate::DB.get() { + let s = db.read(); + t.set("voice", s.voice.clone())?; + t.set("microphone", s.microphone)?; + t.set("vosk_model", s.vosk_model.clone())?; + t.set("noise_suppression", format!("{:?}", s.noise_suppression))?; + } + + Ok(t) + })?; + jarvis.set("health", health_fn)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/llm.rs b/crates/jarvis-core/src/lua/api/llm.rs index a966694..ab430d8 100644 --- a/crates/jarvis-core/src/lua/api/llm.rs +++ b/crates/jarvis-core/src/lua/api/llm.rs @@ -98,5 +98,25 @@ pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { })?; jarvis.set("llm_switch", switch_fn)?; + // Clear conversation turns (keeps system prompt). Returns true if history was + // initialised, false if LLM is not configured. + let reset_fn = lua.create_function(|_, ()| { + llm::history_clear(); + Ok(true) + })?; + jarvis.set("llm_reset", reset_fn)?; + + // Return the most recent assistant message text, or nil. + let last_fn = lua.create_function(|lua, ()| { + match llm::history_last_assistant() { + Some(text) => { + let s = lua.create_string(text)?; + Ok(Value::String(s)) + } + None => Ok(Value::Nil), + } + })?; + jarvis.set("llm_last_reply", last_fn)?; + Ok(()) } diff --git a/crates/jarvis-core/src/lua/engine.rs b/crates/jarvis-core/src/lua/engine.rs index 1c2f7b8..a481b3e 100644 --- a/crates/jarvis-core/src/lua/engine.rs +++ b/crates/jarvis-core/src/lua/engine.rs @@ -82,6 +82,7 @@ impl LuaEngine { api::profile::register(&self.lua, &jarvis)?; api::scheduler::register(&self.lua, &jarvis)?; api::cmd::register(&self.lua, &jarvis)?; + api::health::register(&self.lua, &jarvis)?; // sandbox-controlled APIs if self.sandbox.allows_http() { diff --git a/resources/commands/diagnostics/command.toml b/resources/commands/diagnostics/command.toml new file mode 100644 index 0000000..aa39d5b --- /dev/null +++ b/resources/commands/diagnostics/command.toml @@ -0,0 +1,21 @@ +# Diagnostics — speak a summary of the active runtime state. Useful when +# something is misbehaving and you want to know which backends are active. + +[[commands]] +id = "diagnostics.health" +type = "lua" +script = "health.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "диагностика", + "состояние системы", + "статус", + "что у тебя сейчас", + "проверка системы", + "доложи о себе", +] +en = ["diagnostics", "status report", "health check"] +ua = ["діагностика", "стан системи"] diff --git a/resources/commands/diagnostics/health.lua b/resources/commands/diagnostics/health.lua new file mode 100644 index 0000000..e38c360 --- /dev/null +++ b/resources/commands/diagnostics/health.lua @@ -0,0 +1,11 @@ +local h = jarvis.health() +local line = string.format( + "TTS: %s. LLM: %s. Профиль: %s. Памяти фактов: %d. Запланировано: %d. Язык: %s.", + h.tts_backend or "—", + h.llm_backend or "—", + h.active_profile or "—", + h.memory_facts or 0, + h.scheduled_tasks or 0, + h.language or "—" +) +return jarvis.cmd.ok(line) diff --git a/resources/commands/llm_context/command.toml b/resources/commands/llm_context/command.toml new file mode 100644 index 0000000..4fcd61e --- /dev/null +++ b/resources/commands/llm_context/command.toml @@ -0,0 +1,39 @@ +# P0.3: Conversation context controls — reset / repeat last reply. + +[[commands]] +id = "llm.reset" +type = "lua" +script = "reset.lua" +sandbox = "standard" +timeout = 2000 + +[commands.phrases] +ru = [ + "сбрось контекст", + "сбрось разговор", + "забудь о чём мы говорили", + "забудь разговор", + "начни разговор заново", + "очисти контекст", +] +en = ["reset context", "forget conversation", "start fresh"] +ua = ["скинь контекст", "забудь розмову"] + + +[[commands]] +id = "llm.repeat" +type = "lua" +script = "repeat.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "повтори последнее", + "повтори что сказал", + "повтори ответ", + "что ты сказал", + "повтори", +] +en = ["repeat", "say it again", "repeat the last answer"] +ua = ["повтори", "повтори що сказав"] diff --git a/resources/commands/llm_context/repeat.lua b/resources/commands/llm_context/repeat.lua new file mode 100644 index 0000000..6b86adb --- /dev/null +++ b/resources/commands/llm_context/repeat.lua @@ -0,0 +1,5 @@ +local last = jarvis.llm_last_reply() +if not last or last == "" then + return jarvis.cmd.not_found("Мне нечего повторить.") +end +return jarvis.cmd.ok(last) diff --git a/resources/commands/llm_context/reset.lua b/resources/commands/llm_context/reset.lua new file mode 100644 index 0000000..6ef82b2 --- /dev/null +++ b/resources/commands/llm_context/reset.lua @@ -0,0 +1,2 @@ +jarvis.llm_reset() +return jarvis.cmd.ok("Контекст сброшен.") diff --git a/resources/commands/quick_search/command.toml b/resources/commands/quick_search/command.toml new file mode 100644 index 0000000..5c73f98 --- /dev/null +++ b/resources/commands/quick_search/command.toml @@ -0,0 +1,31 @@ +# Quick-search-read — answer questions using DuckDuckGo Instant Answer API + LLM. +# No API key required. Falls back to pure LLM if web search returns nothing. + +[[commands]] +id = "quick_search" +type = "lua" +script = "search.lua" +sandbox = "full" +timeout = 30000 + +[commands.phrases] +ru = [ + "найди в гугле", + "найди в интернете", + "поищи в гугле", + "найди и расскажи", + "загугли", + "посмотри в гугле", + "поищи информацию", + "найди информацию о", +] +en = [ + "google for", + "search for", + "find online", + "look up", +] +ua = [ + "знайди в гуглі", + "погугли", +] diff --git a/resources/commands/quick_search/search.lua b/resources/commands/quick_search/search.lua new file mode 100644 index 0000000..fb3c445 --- /dev/null +++ b/resources/commands/quick_search/search.lua @@ -0,0 +1,83 @@ +-- "Найди в гугле что такое квантовая запутанность" +-- Uses DuckDuckGo's free Instant Answer API (api.duckduckgo.com). No key. +-- The IA response often has a "Abstract" / "AbstractText" / "AbstractURL" set +-- for well-known topics. We feed Abstract + RelatedTopics to the LLM for a +-- spoken summary. Falls back to pure LLM knowledge if DDG returns nothing useful. + +local phrase = (jarvis.context.phrase or "") + +local query = jarvis.text.strip_trigger(phrase:lower(), { + "найди и расскажи", + "найди в гугле", + "найди в интернете", + "поищи в гугле", + "посмотри в гугле", + "загугли", + "поищи информацию о", + "поищи информацию", + "найди информацию о", + "google for", + "search for", + "find online", + "look up", + "знайди в гуглі", + "погугли", +}) +query = query:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if query == "" then + return jarvis.cmd.error("Что искать?") +end + +local function urlencode(s) + return (s:gsub("[^A-Za-z0-9%-_%.~]", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local url = "https://api.duckduckgo.com/?q=" .. urlencode(query) + .. "&format=json&no_html=1&skip_disambig=1" + +local data = jarvis.http.json(url) + +local context = "" +if data then + if data.AbstractText and data.AbstractText ~= "" then + context = data.AbstractText + if data.AbstractURL and data.AbstractURL ~= "" then + context = context .. "\nИсточник: " .. data.AbstractURL + end + elseif data.RelatedTopics and #data.RelatedTopics > 0 then + local lines = {} + for i = 1, math.min(4, #data.RelatedTopics) do + local t = data.RelatedTopics[i] + if t.Text then table.insert(lines, t.Text) end + end + context = table.concat(lines, "\n") + end +end + +local user_prompt +if context ~= "" then + user_prompt = string.format( + "Вопрос пользователя: %s\n\nДанные из веб-поиска:\n%s\n\nОтветь на вопрос по-русски, кратко (2-4 предложения), используя данные. Если данных мало — добавь из своих знаний.", + query, context + ) +else + user_prompt = string.format( + "Вопрос пользователя: %s\n\nВеб-поиск ничего полезного не дал. Ответь по-русски кратко (2-4 предложения), используя свои знания. Если не уверен — скажи об этом.", + query + ) +end + +local answer = jarvis.llm({ + { role = "system", content = "Ты — справочный помощник. Отвечай кратко и по делу, без вводных фраз." }, + { role = "user", content = user_prompt } +}, { max_tokens = 350, temperature = 0.3 }) + +if not answer or answer == "" then + return jarvis.cmd.error("Не получилось получить ответ.") +end + +jarvis.system.notify("Поиск: " .. query, answer:sub(1, 200)) +return jarvis.cmd.ok(answer)