feat: /history page (recognition log) + multi wake-word loading
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run

# 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 `<APP_CONFIG_DIR>/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.
This commit is contained in:
Bossiara13 2026-05-24 23:23:43 +03:00
parent 965441d4db
commit 73fc404ec7
13 changed files with 867 additions and 63 deletions

View file

@ -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)
});

View file

@ -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();

View file

@ -163,4 +163,5 @@ settings-profile = Profile
header-macros = Macros
header-scheduler = Schedule
header-memory = Memory
header-plugins = Plugins
header-plugins = Plugins
header-history = History

View file

@ -163,4 +163,5 @@ settings-profile = Профиль
header-macros = Макросы
header-scheduler = Расписание
header-memory = Память
header-plugins = Плагины
header-plugins = Плагины
header-history = История

View file

@ -68,6 +68,8 @@ pub mod wake_trainer;
pub mod idle_banter;
pub mod recognition_log;
#[cfg(feature = "lua")]
pub mod toast;

View file

@ -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(())
}

View file

@ -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
//! `<APP_CONFIG_DIR>/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<u8>,
/// 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<VecDeque<RecognitionEntry>>,
}
static STATE: OnceCell<State> = 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<RecognitionEntry> {
if !path.is_file() {
return Vec::new();
}
match std::fs::read_to_string(path) {
Ok(text) => match serde_json::from_str::<Vec<RecognitionEntry>>(&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<String>, source: impl Into<String>, 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<RecognitionEntry> {
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<RecognitionEntry> {
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<RecognitionEntry> = 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<RecognitionEntry> = 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<RecognitionEntry> = (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);
}
}

View file

@ -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,

View file

@ -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::*;

View file

@ -0,0 +1,91 @@
//! Tauri commands for the /history page — recognition log with
//! green/red status badges. The data lives in
//! `<APP_CONFIG_DIR>/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<String>,
pub confidence_pct: Option<u8>,
pub via: Option<String>,
pub success: Option<bool>,
pub error_message: Option<String>,
}
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<HistoryEntry> {
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()
}

View file

@ -186,6 +186,10 @@
<span class="btn-text">{t('header-plugins') || 'Плагины'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/history')} title="Recognition history">
<span class="btn-text">{t('header-history') || 'История'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/settings')}>
<span class="btn-text">{t('header-settings')}</span>
</button>

View file

@ -0,0 +1,313 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, Loader, TextInput,
} from "@svelteuidev/core"
import { CrossCircled, Reload, Trash, MagnifyingGlass } from "radix-icons-svelte"
interface HistoryEntry {
ts: number
phrase: string
source: string
kind: "matched" | "not_found" | "llm_handled" | "error"
command_id: string | null
confidence_pct: number | null
via: string | null
success: boolean | null
error_message: string | null
}
let entries: HistoryEntry[] = []
let loading = true
let error = ""
let filter = ""
// Poll the disk-backed log every 2s so daemon writes show up live.
// Backed by `history_recent` which re-reads recognition_log.json each
// call — see crates/jarvis-gui/src/tauri_commands/history.rs.
let pollHandle: ReturnType<typeof setInterval> | null = null
async function load() {
try {
entries = await invoke<HistoryEntry[]>("history_recent", { limit: 200 })
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function clearAll() {
if (!confirm("Очистить всю историю распознаваний?")) return
try {
await invoke<number>("history_clear")
await load()
} catch (e) {
error = String(e)
}
}
onMount(() => {
load()
pollHandle = setInterval(load, 2000)
})
onDestroy(() => {
if (pollHandle !== null) clearInterval(pollHandle)
})
function fmtTime(ts: number): string {
const d = new Date(ts * 1000)
const now = new Date()
const sameDay = d.toDateString() === now.toDateString()
if (sameDay) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function kindColor(e: HistoryEntry): "lime" | "red" | "orange" | "blue" {
if (e.kind === "matched") return e.success ? "lime" : "red"
if (e.kind === "llm_handled") return "blue"
if (e.kind === "error") return "red"
return "orange" // not_found
}
function kindLabel(e: HistoryEntry): string {
switch (e.kind) {
case "matched": return e.success ? "Выполнено" : "Ошибка"
case "not_found": return "Не понял"
case "llm_handled": return "LLM ответил"
case "error": return "Сбой"
}
}
function viaLabel(via: string | null): string {
if (!via) return ""
const map: Record<string, string> = {
intent: "intent",
fuzzy: "fuzzy",
router: "LLM-router",
llm: "LLM",
}
return map[via] || via
}
$: filtered = filter.trim()
? entries.filter(e =>
e.phrase.toLowerCase().includes(filter.toLowerCase())
|| (e.command_id || "").toLowerCase().includes(filter.toLowerCase())
)
: entries
$: matched = entries.filter(e => e.kind === "matched" && e.success).length
$: misses = entries.filter(e => e.kind === "not_found" || e.kind === "error" || (e.kind === "matched" && !e.success)).length
</script>
<Space h="xl" />
<h2 class="page-title">История распознаваний</h2>
<Text size="sm" color="gray">
Каждая фраза, которую Jarvis услышал и обработал. Зелёный — команда
нашлась и выполнилась, оранжевый — не понял, синий — обработал через LLM,
красный — была ошибка. Полезно понимать, что именно надо переформулировать
или дотренировать.
</Text>
<Space h="md" />
<div class="stats-row">
<Badge color="lime" size="md" variant="filled">{matched}</Badge>
&nbsp;
<Badge color="orange" size="md" variant="filled">{misses}</Badge>
&nbsp;
<Badge color="gray" size="md" variant="light">всего {entries.length}</Badge>
</div>
<Space h="md" />
<div class="actions-row">
<TextInput
placeholder="Фильтр по фразе или команде…"
variant="filled"
bind:value={filter}
icon={MagnifyingGlass}
/>
&nbsp;
<Button color="gray" radius="md" size="sm" on:click={load}>
<Reload size={14} /> &nbsp; Обновить
</Button>
&nbsp;
<Button color="red" radius="md" size="sm" variant="outline" on:click={clearAll}>
<Trash size={14} /> &nbsp; Очистить
</Button>
</div>
<Space h="md" />
{#if error}
<Notification
title="Ошибка"
icon={CrossCircled}
color="red"
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if filtered.length === 0}
<Text size="sm" color="gray">
{entries.length === 0
? "История пуста. Скажи что-нибудь — появится здесь."
: "Под фильтр ничего не подходит."}
</Text>
{:else}
<div class="entry-list">
{#each filtered as e}
<div class="entry-card kind-{e.kind}">
<div class="entry-header">
<span class="entry-phrase">«{e.phrase}»</span>
<Badge color={kindColor(e)} variant="filled" size="sm">
{kindLabel(e)}
</Badge>
</div>
<div class="entry-meta">
<small>{fmtTime(e.ts)}</small>
{#if e.command_id}
<small><code>{e.command_id}</code></small>
{/if}
{#if e.via}
<small>через {viaLabel(e.via)}</small>
{/if}
{#if e.confidence_pct !== null}
<small>{e.confidence_pct}% увер.</small>
{/if}
<small class="source-tag">{e.source}</small>
</div>
{#if e.error_message}
<div class="entry-error">{e.error_message}</div>
{/if}
</div>
{/each}
</div>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.stats-row {
display: flex;
align-items: center;
}
.actions-row {
display: flex;
gap: 0.5rem;
align-items: center;
:global(.svelteui-Input-root) {
flex: 1;
}
}
.entry-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.entry-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 255, 255, 0.06);
border-left: 4px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 0.6rem 0.85rem;
&.kind-matched {
border-left-color: rgba(132, 204, 22, 0.7);
}
&.kind-not_found {
border-left-color: rgba(249, 115, 22, 0.7);
opacity: 0.85;
}
&.kind-llm_handled {
border-left-color: rgba(59, 130, 246, 0.7);
}
&.kind-error {
border-left-color: rgba(239, 68, 68, 0.85);
}
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.entry-phrase {
font-size: 0.95rem;
font-weight: 500;
color: #fff;
word-break: break-word;
}
.entry-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.55);
font-size: 0.72rem;
code {
background: rgba(255, 255, 255, 0.06);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.85);
}
.source-tag {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
}
.entry-error {
color: rgba(255, 120, 120, 0.95);
background: rgba(120, 30, 30, 0.2);
font-size: 0.75rem;
padding: 0.3rem 0.5rem;
border-radius: 4px;
margin-top: 0.4rem;
}
</style>

View file

@ -715,6 +715,19 @@
<Space h="sm" />
<Button
color="grape"
radius="md"
size="sm"
uppercase
fullSize
on:click={() => $goto("/wake-trainer")}
>
Обучить wake-word (записать свой голос)
</Button>
<Space h="sm" />
<Button
color="blue"
radius="md"