J.A.R.V.I.S-rust/crates/jarvis-core/src/llm/history.rs

144 lines
4 KiB
Rust
Raw Normal View History

use super::client::ChatMessage;
pub struct ConversationHistory {
system: Option<ChatMessage>,
turns: Vec<ChatMessage>,
max_turns: usize,
}
impl ConversationHistory {
pub fn new(system_prompt: impl Into<String>, 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<String>) {
self.turns.push(ChatMessage::user(content));
self.truncate();
}
pub fn push_assistant(&mut self, content: impl Into<String>) {
self.turns.push(ChatMessage::assistant(content));
self.truncate();
}
pub fn snapshot(&self) -> Vec<ChatMessage> {
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<ChatMessage> {
if matches!(self.turns.last(), Some(m) if m.role == "user") {
self.turns.pop()
} else {
None
}
}
feat: persist LLM/TTS backend choice + reset/repeat context + quick-search + diagnostics Closes the UX hole I created in 5c72450: voice-swap to Ollama used to vanish after restart. Plus P0.3 (long-standing roadmap item) and two new packs. Persistent backend choice (crates/jarvis-core/src/db/structs.rs) - Settings struct gains llm_backend + tts_backend fields (both String, "" / "auto" = follow env/auto-detect). - set("llm_backend", "groq"|"ollama"|"auto") validates input. - llm::init_global() reads DB first, then JARVIS_LLM env, then auto-detect. - llm::swap_to() now persists the choice via db::save_settings. - Voice swap "переключись на локальный" now survives restart. Shared conversation history (P0.3, crates/jarvis-core/src/llm/mod.rs) - HISTORY: Lazy<RwLock<Option<ConversationHistory>>> singleton. - Helpers: init_history, history_push_user, history_push_assistant, history_snapshot, history_clear, history_pop_last_user, history_last_assistant. - llm_fallback migrated off its own Mutex<History> — now reads/writes shared. - ConversationHistory gains last_assistant() method. New Lua APIs - jarvis.llm_reset() → clear conversation turns (keeps system prompt). - jarvis.llm_last_reply() → string or nil (last assistant message text). - jarvis.health() → debug table {tts_backend, llm_backend, llm_model, active_profile, memory_facts, scheduled_tasks, language, voice, microphone, vosk_model, noise_suppression}. No secrets included. New voice packs - resources/commands/llm_context/ (P0.3) * "сбрось контекст" / "забудь разговор" → llm.reset * "повтори последнее" / "повтори ответ" → llm.repeat (uses last_assistant) - resources/commands/quick_search/ (imba P1 item) * "найди в гугле <X>" / "загугли <X>" * Uses DuckDuckGo Instant Answer API (api.duckduckgo.com, no key required). Pulls AbstractText or RelatedTopics into LLM prompt; falls back to pure LLM knowledge if DDG returns nothing useful. Speaks 2-4 sentence answer. - resources/commands/diagnostics/ * "диагностика" / "доложи о себе" / "статус" * Reads jarvis.health() and speaks a one-line summary. Useful when debugging — user can read out their current state for a bug report. Build: cargo build --release -p jarvis-app -p jarvis-gui green. Tests: 52/52 jarvis-core unit tests pass.
2026-05-15 16:25:28 +03:00
/// 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;
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");
}
}