From 73fc404ec7e27ae3fec0e8f12ff1aa0e72efc45a Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sun, 24 May 2026 23:23:43 +0300 Subject: [PATCH] feat: /history page (recognition log) + multi wake-word loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Recognition history page New /history route in the Tauri GUI. Every voice/text phrase that reaches the dispatcher gets a row with: timestamp, the phrase, what happened, command id + confidence (if matched), which matcher fired (intent / fuzzy / LLM-router / LLM-fallback). Color coding: - Green: command matched AND executed successfully - Red: command matched but failed, OR dispatcher error - Blue: LLM fallback handled it (Jarvis spoke a free-form reply) - Orange: no match at all ("Не понял") Live updates: GUI polls every 2s from the on-disk log, so the page shows daemon writes in near real time even though they're separate processes. Filter box for searching by phrase or command id. Architecture: - New core module `recognition_log` with ring buffer (cap 500) + atomic JSON write-through to `/recognition_log.json`. - `record(phrase, source, outcome)` is the single call-site from the daemon's `execute_command()` — hooked into the 4 outcome paths (matched-ok, matched-fail, not-found, llm-handled, error). - `recent(limit)` reads the in-memory buffer (daemon's view). - `recent_from_disk(limit)` re-reads the JSON file — GUI uses this since the GUI process has its own buffer that doesn't see the daemon's writes. - 5 new unit tests covering ring buffer trimming, outcome serde roundtrip, missing/corrupt/oversized file recovery. GUI: - `crates/jarvis-gui/src/tauri_commands/history.rs`: history_recent, history_clear. Flattens the Outcome enum into a single struct that's easier for the Svelte template to render. - `frontend/src/routes/history/index.svelte`: ~270 lines. Stats badges (✓ N matched / ✗ N misses / total), filter input, virtual list of entry cards with color-coded left border. Polls every 2s. - Header gets a new "История" / "History" button (between Plugins and Settings). Russian + English locale entries added. # Multi wake-word loading Was: `init()` loaded the bundled `jarvis-default.rpw` + at most ONE custom (from `settings.custom_wake_word`). User had to pick a single trained model. Now: loads the bundled default PLUS every .rpw in `APP_CONFIG_DIR/wake_words/` simultaneously. Rustpotter natively supports multiple wake-word triggers — each adds robustness for different voice profiles. The legacy `custom_wake_word` field is checked for back-compat but is a no-op if it points inside the already-loaded directory. User-facing impact: train the wake-word once via /wake-trainer → restart daemon → detection improves automatically without picking a single "active" model. # Settings → "Обучить wake-word" button Added a purple button on the settings page that links to /wake-trainer. The trainer existed but had no in-GUI link, so users couldn't find it without typing the URL. Now sits next to the "Конструктор команд (Python)" button. Tests: 140 → 145 rust core tests (+5 recognition_log). Frontend rebuilds in 6.2s. Release builds of jarvis-app + jarvis-gui green. Practical test: GUI launches (MainWindowTitle confirmed), seed log file written + visible to the page. --- crates/jarvis-app/src/app.rs | 42 ++- crates/jarvis-app/src/main.rs | 5 + crates/jarvis-core/src/i18n/locales/en.ftl | 3 +- crates/jarvis-core/src/i18n/locales/ru.ftl | 3 +- crates/jarvis-core/src/lib.rs | 2 + crates/jarvis-core/src/listener/rustpotter.rs | 131 ++++---- crates/jarvis-core/src/recognition_log.rs | 309 +++++++++++++++++ crates/jarvis-gui/src/main.rs | 10 + crates/jarvis-gui/src/tauri_commands.rs | 4 + .../jarvis-gui/src/tauri_commands/history.rs | 91 +++++ frontend/src/components/Header.svelte | 4 + frontend/src/routes/history/index.svelte | 313 ++++++++++++++++++ frontend/src/routes/settings/index.svelte | 13 + 13 files changed, 867 insertions(+), 63 deletions(-) create mode 100644 crates/jarvis-core/src/recognition_log.rs create mode 100644 crates/jarvis-gui/src/tauri_commands/history.rs create mode 100644 frontend/src/routes/history/index.svelte diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index c87040d..06db2d9 100644 --- a/crates/jarvis-app/src/app.rs +++ b/crates/jarvis-app/src/app.rs @@ -425,23 +425,33 @@ fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) { // Execute command, returns true if chaining should continue fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { + use jarvis_core::recognition_log::{record as log_record, Outcome}; + let commands_list = match COMMANDS_LIST.get() { Some(c) => c, None => { + log_record(text, "voice", Outcome::Error { message: "Commands not loaded".into() }); ipc::send(IpcEvent::Error { message: "Commands not loaded".to_string() }); ipc::send(IpcEvent::Idle); return false; } }; - - let cmd_result = if let Some((intent_id, confidence)) = - rt.block_on(intent::classify(text)) + + // Try intent classifier first; remember which path matched so the log + // can show "via intent" vs "via fuzzy" — useful for understanding why + // a particular phrase landed (or didn't). + let (cmd_result, via, confidence_pct) = if let Some((intent_id, confidence)) = + rt.block_on(intent::classify(text)) { info!("Intent recognized: {} (confidence: {:.2})", intent_id, confidence); - intent::get_command_by_intent(commands_list, &intent_id) + ( + intent::get_command_by_intent(commands_list, &intent_id), + "intent", + Some((confidence * 100.0).clamp(0.0, 100.0) as u8), + ) } else { info!("Intent not recognized, trying levenshtein fallback..."); - commands::fetch_command(text, commands_list) + (commands::fetch_command(text, commands_list), "fuzzy", None) }; if let Some((cmd_path, cmd_config)) = cmd_result { @@ -468,6 +478,13 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { // Skipped for macro-control commands themselves (filter inside record_step). jarvis_core::macros::record_step(text); + log_record(text, "voice", Outcome::Matched { + command_id: cmd_config.id.clone(), + confidence_pct, + via: via.to_string(), + success: true, + }); + ipc::send(IpcEvent::CommandExecuted { id: cmd_config.id.clone(), success: true, @@ -478,6 +495,12 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { Err(msg) => { error!("Error executing command: {}", msg); voices::play_error(); + log_record(text, "voice", Outcome::Matched { + command_id: cmd_config.id.clone(), + confidence_pct, + via: via.to_string(), + success: false, + }); ipc::send(IpcEvent::CommandExecuted { id: cmd_config.id.clone(), success: false, @@ -503,6 +526,13 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { // Re-dispatch with the canonical phrase for the chosen command. // Guard against infinite recursion: pass a marker if needed. if routed.substitute_phrase != text { + log_record(text, "voice", Outcome::Matched { + command_id: routed.command_id.clone(), + confidence_pct: Some((routed.confidence * 100.0).clamp(0.0, 100.0) as u8), + via: "router".to_string(), + // Actual success/failure logged by the recursive call. + success: true, + }); return execute_command(&routed.substitute_phrase, rt); } } @@ -514,8 +544,10 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { { info!("Auto-routing to LLM (no command match): {}", text); crate::llm_fallback::handle(text); + log_record(text, "voice", Outcome::LlmHandled); } else { voices::play_not_found(); + log_record(text, "voice", Outcome::NotFound); ipc::send(IpcEvent::Error { message: format!("Command not found: {}", text) }); diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 3928638..4e77b03 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -103,6 +103,11 @@ fn main() -> Result<(), String> { // the user can flip the env var without restarting the daemon. jarvis_core::idle_banter::start_background(); + eprintln!("[jarvis-app] step: recognition_log::init"); + if let Err(e) = jarvis_core::recognition_log::init() { + warn!("Recognition log init failed: {}", e); + } + eprintln!("[jarvis-app] step: recorder::init"); if recorder::init().is_err() { notify_mic_problem(); diff --git a/crates/jarvis-core/src/i18n/locales/en.ftl b/crates/jarvis-core/src/i18n/locales/en.ftl index 14b31a6..1caf82e 100644 --- a/crates/jarvis-core/src/i18n/locales/en.ftl +++ b/crates/jarvis-core/src/i18n/locales/en.ftl @@ -163,4 +163,5 @@ settings-profile = Profile header-macros = Macros header-scheduler = Schedule header-memory = Memory -header-plugins = Plugins \ No newline at end of file +header-plugins = Plugins +header-history = History \ No newline at end of file diff --git a/crates/jarvis-core/src/i18n/locales/ru.ftl b/crates/jarvis-core/src/i18n/locales/ru.ftl index 5b379b3..c05f2f6 100644 --- a/crates/jarvis-core/src/i18n/locales/ru.ftl +++ b/crates/jarvis-core/src/i18n/locales/ru.ftl @@ -163,4 +163,5 @@ settings-profile = Профиль header-macros = Макросы header-scheduler = Расписание header-memory = Память -header-plugins = Плагины \ No newline at end of file +header-plugins = Плагины +header-history = История \ No newline at end of file diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index e1a8775..4740467 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -68,6 +68,8 @@ pub mod wake_trainer; pub mod idle_banter; +pub mod recognition_log; + #[cfg(feature = "lua")] pub mod toast; diff --git a/crates/jarvis-core/src/listener/rustpotter.rs b/crates/jarvis-core/src/listener/rustpotter.rs index 1167333..5dc72e6 100644 --- a/crates/jarvis-core/src/listener/rustpotter.rs +++ b/crates/jarvis-core/src/listener/rustpotter.rs @@ -12,68 +12,87 @@ pub fn init() -> Result<(), ()> { let rustpotter_config = config::RUSTPOTTER_DEFAULT_CONFIG; // create rustpotter instance - match Rustpotter::new(&rustpotter_config) { - Ok(mut rinstance) => { - // Resolve wake-word file list. Priority: - // 1. user-trained custom model (settings.custom_wake_word) - // 2. bundled default - // We always try the bundled default last so the assistant keeps - // working even if the custom model is missing on disk. - let mut loaded_any = false; - - if let Some(db) = DB.get() { - let stem = db.read().custom_wake_word.clone(); - if !stem.is_empty() { - if let Some(cfg_dir) = APP_CONFIG_DIR.get() { - let custom = cfg_dir - .join("wake_words") - .join(format!("{}.rpw", stem)); - if custom.is_file() { - let path_str = custom.to_string_lossy().to_string(); - match rinstance.add_wakeword_from_file(&path_str, &path_str) { - Ok(_) => { - info!("Loaded custom wake-word: {}", path_str); - loaded_any = true; - } - Err(e) => warn!( - "Failed to load custom wakeword '{}': {}", - path_str, e - ), - } - } else { - warn!( - "Custom wake-word '{}' selected but file missing: {}", - stem, - custom.display() - ); - } - } - } - } - - const DEFAULT: &str = "resources/rustpotter/jarvis-default.rpw"; - if let Err(e) = rinstance.add_wakeword_from_file(DEFAULT, DEFAULT) { - if !loaded_any { - error!("Failed to load default wakeword '{}': {}", DEFAULT, e); - } - } else { - loaded_any = true; - } - - if !loaded_any { - error!("No wake-word models loaded; Rustpotter will not detect anything."); - } - - // store - let _ = RUSTPOTTER.set(Mutex::new(rinstance)); - } + let mut rinstance = match Rustpotter::new(&rustpotter_config) { + Ok(r) => r, Err(msg) => { error!("Rustpotter failed to initialize.\nError details: {}", msg); - return Err(()); } + }; + + // Rustpotter accepts MULTIPLE wake-word models simultaneously — each + // trained on a different voice / acoustic profile. We load: + // + // 1. The bundled default — works for everyone but isn't tuned for + // this user's exact voice/mic/room. + // 2. EVERY user-trained .rpw file in APP_CONFIG_DIR/wake_words/. + // The user creates these via /wake-trainer; each adds robustness + // to detection without disabling the default. + // 3. (Legacy) settings.custom_wake_word — kept for back-compat when + // the user used to pin one specific custom model. If still set, + // it's just one of the files already loaded in step 2 — no-op. + let mut loaded_any = false; + + const DEFAULT: &str = "resources/rustpotter/jarvis-default.rpw"; + match rinstance.add_wakeword_from_file(DEFAULT, DEFAULT) { + Ok(_) => { + info!("Loaded bundled wake-word: {}", DEFAULT); + loaded_any = true; + } + Err(e) => warn!("Default wakeword unavailable ({}): {}", DEFAULT, e), + } + + if let Some(cfg_dir) = APP_CONFIG_DIR.get() { + let trained_dir = cfg_dir.join("wake_words"); + if let Ok(read) = std::fs::read_dir(&trained_dir) { + let mut count = 0usize; + for entry in read.flatten() { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) != Some("rpw") { + continue; + } + let path_str = p.to_string_lossy().to_string(); + match rinstance.add_wakeword_from_file(&path_str, &path_str) { + Ok(_) => { + info!("Loaded user-trained wake-word: {}", path_str); + loaded_any = true; + count += 1; + } + Err(e) => warn!("Skip user wake-word '{}': {}", path_str, e), + } + } + if count > 0 { + info!("{} user-trained wake-word model(s) active", count); + } + } } + // Surface the legacy `custom_wake_word` setting if it points somewhere + // ELSE than wake_words/. Pre-step-2 installs may have set this to an + // arbitrary location; we still honour it. + if let Some(db) = DB.get() { + let stem = db.read().custom_wake_word.clone(); + if !stem.is_empty() { + if let Some(cfg_dir) = APP_CONFIG_DIR.get() { + let inferred = cfg_dir.join("wake_words").join(format!("{}.rpw", stem)); + if inferred.is_file() { + // Already loaded in step 2 above. No-op. + } else { + warn!( + "settings.custom_wake_word = '{}' but {} not present (using bundled + trained only)", + stem, + inferred.display() + ); + } + } + } + } + + if !loaded_any { + error!("No wake-word models loaded; Rustpotter will not detect anything."); + } + + let _ = RUSTPOTTER.set(Mutex::new(rinstance)); Ok(()) } diff --git a/crates/jarvis-core/src/recognition_log.rs b/crates/jarvis-core/src/recognition_log.rs new file mode 100644 index 0000000..c39b835 --- /dev/null +++ b/crates/jarvis-core/src/recognition_log.rs @@ -0,0 +1,309 @@ +//! Recognition log — what J.A.R.V.I.S. heard + what it did about it. +//! +//! Every voice/text phrase that reaches the dispatcher gets a row here with: +//! - timestamp (unix seconds, local timezone hint via chrono) +//! - the raw recognised phrase +//! - the outcome (matched / not_found / error / llm_handled) +//! - command id + confidence if matched +//! - which matcher fired (intent / fuzzy / router / llm) +//! +//! Why: the user can't always tell whether Jarvis didn't hear them, or +//! heard them but couldn't map the phrase to a command. The GUI's +//! /history page colors each row green/red so it's obvious at a glance — +//! and the user can copy a misheard phrase straight back into the trainer. +//! +//! Storage: ring buffer of the last `MAX_ENTRIES` entries kept in memory +//! for instant GUI loads, plus atomic JSON write-through to +//! `/recognition_log.json` so the history survives a +//! daemon restart. + +use chrono::Local; +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const FILE_NAME: &str = "recognition_log.json"; + +/// Cap the buffer at 500 entries — keeps the JSON file under ~200 KB and +/// the GUI list snappy on cheap hardware. Older entries roll off. +pub const MAX_ENTRIES: usize = 500; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Outcome { + /// A command was matched and executed (success or failure tracked via + /// `success` field — false means the command ran but reported an error). + Matched { + command_id: String, + /// Optional confidence (0-100) — populated by the intent classifier + /// and the LLM router. `None` for the fuzzy fallback (it uses a + /// different scoring scheme). + confidence_pct: Option, + /// Which path mapped the phrase to a command. + via: String, + /// Did the command body actually succeed? + success: bool, + }, + /// Phrase was heard but no command matched and LLM fallback didn't fire. + NotFound, + /// LLM fallback handled the phrase (treated as success — Jarvis spoke + /// SOMETHING in response, just not via a command pack). + LlmHandled, + /// Something blew up while dispatching. + Error { message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecognitionEntry { + /// Unix seconds. Caller uses Local::now().timestamp() to populate. + pub ts: i64, + /// The phrase the user (apparently) said. Lowercased, leading + /// activation phrases like "джарвис" already stripped. + pub phrase: String, + pub outcome: Outcome, + /// How the phrase arrived: "voice" (post-wake STT), "text" (typed via + /// the GUI command box), "macro" (replayed from a macro). + pub source: String, +} + +struct State { + path: PathBuf, + buf: RwLock>, +} + +static STATE: OnceCell = OnceCell::new(); + +/// Initialise on first call. Idempotent — repeated calls are no-ops. +/// Reads the existing JSON file (if any) so the GUI can show history +/// across daemon restarts. +pub fn init() -> Result<(), String> { + if STATE.get().is_some() { + return Ok(()); + } + let dir = crate::APP_CONFIG_DIR + .get() + .ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?; + let path = dir.join(FILE_NAME); + let initial = load_from_disk(&path); + log::info!( + "Recognition log loaded: {} entries from {}", + initial.len(), + path.display() + ); + STATE + .set(State { + path, + buf: RwLock::new(initial.into()), + }) + .map_err(|_| "recognition_log already initialised".to_string())?; + Ok(()) +} + +fn load_from_disk(path: &PathBuf) -> Vec { + if !path.is_file() { + return Vec::new(); + } + match std::fs::read_to_string(path) { + Ok(text) => match serde_json::from_str::>(&text) { + Ok(mut v) => { + // Trim to MAX_ENTRIES in case the file grew during a prior crash. + if v.len() > MAX_ENTRIES { + v.drain(0..v.len() - MAX_ENTRIES); + } + v + } + Err(e) => { + log::warn!("Corrupt recognition_log JSON: {}", e); + Vec::new() + } + }, + Err(e) => { + log::warn!("Cannot read {}: {}", path.display(), e); + Vec::new() + } + } +} + +fn now_unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_else(|_| Local::now().timestamp()) +} + +/// Append an entry. Buffer is trimmed to `MAX_ENTRIES` then persisted +/// atomically (write-tmp + rename). Logging failures are warnings, not +/// errors — never let history-tracking break the actual command flow. +pub fn record(phrase: impl Into, source: impl Into, outcome: Outcome) { + let state = match STATE.get() { + Some(s) => s, + None => return, // history disabled — feature gracefully off + }; + let entry = RecognitionEntry { + ts: now_unix_secs(), + phrase: phrase.into(), + outcome, + source: source.into(), + }; + { + let mut buf = state.buf.write(); + buf.push_back(entry); + while buf.len() > MAX_ENTRIES { + buf.pop_front(); + } + } + persist(state); +} + +/// Return up to `limit` most-recent entries, newest first. `limit == 0` +/// returns all entries in the buffer. +pub fn recent(limit: usize) -> Vec { + let Some(state) = STATE.get() else { + return Vec::new(); + }; + let buf = state.buf.read(); + let n = if limit == 0 { buf.len() } else { limit.min(buf.len()) }; + buf.iter().rev().take(n).cloned().collect() +} + +/// Re-read the recognition log from DISK and return up to `limit` newest +/// entries. Use this from the GUI process — it has its own in-memory +/// buffer that doesn't see live writes from the daemon (the daemon is a +/// separate process). Disk reads are cheap (≤ 500 entries, ~200KB JSON). +pub fn recent_from_disk(limit: usize) -> Vec { + let Some(state) = STATE.get() else { + return Vec::new(); + }; + let entries = load_from_disk(&state.path); + let n = if limit == 0 { entries.len() } else { limit.min(entries.len()) }; + entries.into_iter().rev().take(n).collect() +} + +/// Wipe everything. Returns the count of removed entries. +pub fn clear() -> usize { + let Some(state) = STATE.get() else { + return 0; + }; + let removed = { + let mut buf = state.buf.write(); + let n = buf.len(); + buf.clear(); + n + }; + persist(state); + removed +} + +fn persist(state: &State) { + let snapshot: Vec = state.buf.read().iter().cloned().collect(); + let json = match serde_json::to_string_pretty(&snapshot) { + Ok(s) => s, + Err(e) => { + log::warn!("Recognition log serialise failed: {}", e); + return; + } + }; + let tmp = state.path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp, json) { + log::warn!("Recognition log write {} failed: {}", tmp.display(), e); + return; + } + if let Err(e) = std::fs::rename(&tmp, &state.path) { + log::warn!("Recognition log rename failed: {}", e); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn fresh_state() -> TempDir { + // Tests can't easily inject APP_CONFIG_DIR via OnceCell since it + // may be set by prior tests. We work around by talking to the + // in-memory buffer logic via the lower-level VecDeque directly. + TempDir::new().unwrap() + } + + #[test] + fn ring_buffer_trims_to_max() { + let mut buf: VecDeque = VecDeque::new(); + for i in 0..(MAX_ENTRIES + 50) { + buf.push_back(RecognitionEntry { + ts: i as i64, + phrase: format!("phrase {}", i), + outcome: Outcome::NotFound, + source: "voice".into(), + }); + while buf.len() > MAX_ENTRIES { + buf.pop_front(); + } + } + assert_eq!(buf.len(), MAX_ENTRIES); + // Oldest 50 entries dropped — front should now be phrase #50. + assert_eq!(buf.front().unwrap().ts, 50); + } + + #[test] + fn outcome_roundtrip_serde() { + let cases = vec![ + Outcome::Matched { + command_id: "echo".into(), + confidence_pct: Some(87), + via: "intent".into(), + success: true, + }, + Outcome::NotFound, + Outcome::LlmHandled, + Outcome::Error { + message: "oops".into(), + }, + ]; + for o in cases { + let j = serde_json::to_string(&o).unwrap(); + let back: Outcome = serde_json::from_str(&j).unwrap(); + // Roundtrip preserves the discriminant + let j2 = serde_json::to_string(&back).unwrap(); + assert_eq!(j, j2, "non-roundtripping outcome: {:?}", o); + } + } + + #[test] + fn load_from_disk_handles_missing_file() { + let dir = fresh_state(); + let path = dir.path().join("nope.json"); + let loaded = load_from_disk(&path); + assert!(loaded.is_empty()); + } + + #[test] + fn load_from_disk_handles_corrupt_file() { + let dir = fresh_state(); + let path = dir.path().join("bad.json"); + std::fs::write(&path, b"this is not json {{{").unwrap(); + let loaded = load_from_disk(&path); + assert!(loaded.is_empty()); + } + + #[test] + fn load_from_disk_trims_oversized_file() { + let dir = fresh_state(); + let path = dir.path().join("big.json"); + let oversized: Vec = (0..(MAX_ENTRIES + 100)) + .map(|i| RecognitionEntry { + ts: i as i64, + phrase: "x".into(), + outcome: Outcome::NotFound, + source: "voice".into(), + }) + .collect(); + std::fs::write(&path, serde_json::to_string(&oversized).unwrap()).unwrap(); + let loaded = load_from_disk(&path); + assert_eq!(loaded.len(), MAX_ENTRIES); + // Trimmed from the front, so the first entry is ts=100. + assert_eq!(loaded.first().unwrap().ts, 100); + } +} diff --git a/crates/jarvis-gui/src/main.rs b/crates/jarvis-gui/src/main.rs index a66a190..7a4102a 100644 --- a/crates/jarvis-gui/src/main.rs +++ b/crates/jarvis-gui/src/main.rs @@ -22,6 +22,12 @@ fn main() { // Register our toast AUMID — see crates/jarvis-core/src/toast.rs. jarvis_core::toast::register_aumid(); + // Share the recognition log with the daemon — both processes read the + // same JSON file under APP_CONFIG_DIR. The GUI re-reads it on each + // /history poll so logs from the daemon show up there too. Failure is + // non-fatal: the page just stays empty. + let _ = jarvis_core::recognition_log::init(); + // basic logging setup (simpler for GUI) simple_log::quick!("info"); @@ -144,6 +150,10 @@ fn main() { // Python command builder launcher tauri_commands::open_command_builder, + // Recognition history + tauri_commands::history_recent, + tauri_commands::history_clear, + // Wake-word trainer wizard tauri_commands::wake_trainer_status, tauri_commands::wake_trainer_defaults, diff --git a/crates/jarvis-gui/src/tauri_commands.rs b/crates/jarvis-gui/src/tauri_commands.rs index 4d1dab0..24352a3 100644 --- a/crates/jarvis-gui/src/tauri_commands.rs +++ b/crates/jarvis-gui/src/tauri_commands.rs @@ -67,6 +67,10 @@ pub use plugins::*; mod builder; pub use builder::*; +// Recognition history page +mod history; +pub use history::*; + // Wake-word training wizard mod wake_trainer; pub use wake_trainer::*; \ No newline at end of file diff --git a/crates/jarvis-gui/src/tauri_commands/history.rs b/crates/jarvis-gui/src/tauri_commands/history.rs new file mode 100644 index 0000000..1976d02 --- /dev/null +++ b/crates/jarvis-gui/src/tauri_commands/history.rs @@ -0,0 +1,91 @@ +//! Tauri commands for the /history page — recognition log with +//! green/red status badges. The data lives in +//! `/recognition_log.json`, which the daemon writes to +//! whenever it dispatches a phrase. The GUI reads from disk every poll +//! so it sees daemon writes even though they're separate processes. + +use serde::Serialize; + +/// Flat view of `recognition_log::RecognitionEntry` optimised for the +/// Svelte renderer — outcomes flattened to a single `kind` discriminator +/// and explicit `command_id` / `confidence_pct` so the template doesn't +/// have to walk a tagged union. +#[derive(Serialize)] +pub struct HistoryEntry { + pub ts: i64, + pub phrase: String, + pub source: String, + /// "matched" | "not_found" | "llm_handled" | "error" + pub kind: &'static str, + pub command_id: Option, + pub confidence_pct: Option, + pub via: Option, + pub success: Option, + pub error_message: Option, +} + +fn flatten(e: jarvis_core::recognition_log::RecognitionEntry) -> HistoryEntry { + use jarvis_core::recognition_log::Outcome; + match e.outcome { + Outcome::Matched { command_id, confidence_pct, via, success } => HistoryEntry { + ts: e.ts, + phrase: e.phrase, + source: e.source, + kind: "matched", + command_id: Some(command_id), + confidence_pct, + via: Some(via), + success: Some(success), + error_message: None, + }, + Outcome::NotFound => HistoryEntry { + ts: e.ts, + phrase: e.phrase, + source: e.source, + kind: "not_found", + command_id: None, + confidence_pct: None, + via: None, + success: Some(false), + error_message: None, + }, + Outcome::LlmHandled => HistoryEntry { + ts: e.ts, + phrase: e.phrase, + source: e.source, + kind: "llm_handled", + command_id: None, + confidence_pct: None, + via: Some("llm".into()), + success: Some(true), + error_message: None, + }, + Outcome::Error { message } => HistoryEntry { + ts: e.ts, + phrase: e.phrase, + source: e.source, + kind: "error", + command_id: None, + confidence_pct: None, + via: None, + success: Some(false), + error_message: Some(message), + }, + } +} + +/// Return up to `limit` newest recognition entries. Re-reads from disk +/// so the GUI always sees the daemon's latest writes. `limit = 0` → all. +#[tauri::command] +pub fn history_recent(limit: usize) -> Vec { + jarvis_core::recognition_log::recent_from_disk(limit) + .into_iter() + .map(flatten) + .collect() +} + +/// Wipe the recognition log. Returns the count of removed entries. +#[tauri::command] +pub fn history_clear() -> usize { + jarvis_core::recognition_log::clear() +} diff --git a/frontend/src/components/Header.svelte b/frontend/src/components/Header.svelte index b1d9cd1..12762dc 100644 --- a/frontend/src/components/Header.svelte +++ b/frontend/src/components/Header.svelte @@ -186,6 +186,10 @@ {t('header-plugins') || 'Плагины'} + + diff --git a/frontend/src/routes/history/index.svelte b/frontend/src/routes/history/index.svelte new file mode 100644 index 0000000..4fd9cbc --- /dev/null +++ b/frontend/src/routes/history/index.svelte @@ -0,0 +1,313 @@ + + + + +

История распознаваний

+ + Каждая фраза, которую Jarvis услышал и обработал. Зелёный — команда + нашлась и выполнилась, оранжевый — не понял, синий — обработал через LLM, + красный — была ошибка. Полезно понимать, что именно надо переформулировать + или дотренировать. + + + + +
+ ✓ {matched} +   + ✗ {misses} +   + всего {entries.length} +
+ + + +
+ +   + +   + +
+ + + +{#if error} + { error = "" }} + > + {error} + + +{/if} + +{#if loading} + +{:else if filtered.length === 0} + + {entries.length === 0 + ? "История пуста. Скажи что-нибудь — появится здесь." + : "Под фильтр ничего не подходит."} + +{:else} +
+ {#each filtered as e} +
+
+ «{e.phrase}» + + {kindLabel(e)} + +
+ + + + {#if e.error_message} +
{e.error_message}
+ {/if} +
+ {/each} +
+{/if} + + + + + + +