2026-04-22 23:16:25 +03:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:15:41 +03:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 23:16:25 +03:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:15:41 +03:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 23:16:25 +03:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
}
|