mod client; mod history; pub use client::{ ChatMessage, ConfigError, LlmBackend, LlmClient, LlmError, DEFAULT_BASE_URL, DEFAULT_MODEL, ENV_BASE_URL, ENV_MODEL, ENV_TOKEN, OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, }; pub use history::ConversationHistory; 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. /// /// `None` means LLM is disabled (no GROQ_TOKEN and no Ollama reachable, or /// `init_global()` was never called). static GLOBAL: Lazy>>> = Lazy::new(|| RwLock::new(None)); /// 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> { // 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(()) } /// Return a clone of the active client. Cheap (Arc). pub fn current() -> Option> { GLOBAL.read().clone() } /// Name of the currently active backend, or "none" if not initialised. pub fn current_backend_name() -> &'static str { match GLOBAL.read().as_ref().map(|c| c.backend()) { Some(LlmBackend::Groq) => "groq", Some(LlmBackend::Ollama) => "ollama", None => "none", } } /// 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()?, LlmBackend::Ollama => LlmClient::ollama(), }; 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) } /// Parse a backend name (case-insensitive) into the enum. pub fn parse_backend(name: &str) -> Option { match name.trim().to_lowercase().as_str() { "groq" | "cloud" | "облако" | "клауд" => Some(LlmBackend::Groq), "ollama" | "local" | "локал" | "локальн" | "локальный" => Some(LlmBackend::Ollama), _ => None, } }