feat: /history page (recognition log) + multi wake-word loading
# 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:
parent
965441d4db
commit
73fc404ec7
13 changed files with 867 additions and 63 deletions
|
|
@ -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)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -163,4 +163,5 @@ settings-profile = Профиль
|
|||
header-macros = Макросы
|
||||
header-scheduler = Расписание
|
||||
header-memory = Память
|
||||
header-plugins = Плагины
|
||||
header-plugins = Плагины
|
||||
header-history = История
|
||||
|
|
@ -68,6 +68,8 @@ pub mod wake_trainer;
|
|||
|
||||
pub mod idle_banter;
|
||||
|
||||
pub mod recognition_log;
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod toast;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
309
crates/jarvis-core/src/recognition_log.rs
Normal file
309
crates/jarvis-core/src/recognition_log.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
91
crates/jarvis-gui/src/tauri_commands/history.rs
Normal file
91
crates/jarvis-gui/src/tauri_commands/history.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
313
frontend/src/routes/history/index.svelte
Normal file
313
frontend/src/routes/history/index.svelte
Normal 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>
|
||||
|
||||
<Badge color="orange" size="md" variant="filled">✗ {misses}</Badge>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<Button color="gray" radius="md" size="sm" on:click={load}>
|
||||
<Reload size={14} /> Обновить
|
||||
</Button>
|
||||
|
||||
<Button color="red" radius="md" size="sm" variant="outline" on:click={clearAll}>
|
||||
<Trash size={14} /> Очистить
|
||||
</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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue