feat: Wave 3 — plugin system + custom wake-word trainer

#29 Plugin system:
- New jarvis-core::plugins module: discovers user packs in
  %APPDATA%\com.priler.jarvis\plugins\<name>\command.toml so authors can
  ship voice commands without rebuilding. Sandbox capped at "standard" —
  plugins cannot escalate to "full" (which would expose `os`). Disabled
  via a `disabled` flag file. Malformed packs warn and skip; never poison
  the rest of the list. 8 unit tests.
- commands::parse_commands() merges plugins into the loaded list.
- New /plugins GUI page with enable/disable switches, error reporting,
  "Open folder" button. Tauri commands plugins_list / plugins_set_enabled
  / plugins_open_folder. Header gets a "Плагины" button. i18n keys added
  for ru/en/ua.

#8 Custom wake-word trainer wizard:
- New jarvis-core::wake_trainer module: opens its own pv_recorder
  instance, records N short PCM clips, WAV-encodes them in memory, then
  calls rustpotter's WakewordRef::new_from_sample_buffers to train and
  persist a .rpw model under %APPDATA%/com.priler.jarvis/wake_words/.
  Keepsake WAVs are also dumped for retraining later. Sanitises names to
  block path traversal. 5 unit tests.
- Settings gain `custom_wake_word: String`; listener/rustpotter.rs now
  loads the user's selected model first and always falls back to the
  bundled default so the assistant keeps working even if the custom
  file is missing.
- New /wake-trainer GUI page: stepper UI for record-sample → train,
  shows recorded count, threshold slider, refuses to start while
  jarvis-app is running (mic exclusivity). Lists existing trained
  models with size + delete button.
- 8 Tauri commands wired through (status/defaults/start/record_sample/
  finish/cancel/list_models/delete_model).

Tests: 115 → 128 (+8 plugins +5 wake_trainer). Release builds green for
all three binaries (jarvis-app / jarvis-cli / jarvis-gui).
This commit is contained in:
Bossiara13 2026-05-16 13:26:42 +03:00
parent c4b22618f8
commit 4e21024509
17 changed files with 1625 additions and 24 deletions

View file

@ -93,9 +93,32 @@ LLM через Groq — выбор переключается **голосом**
| "Пауза" / "следующий трек" | Windows media keys (Spotify / YouTube / Foobar) | | "Пауза" / "следующий трек" | Windows media keys (Spotify / YouTube / Foobar) |
| "Диагностика" / "доложи о себе" | Состояние всех бэкендов одной строкой | | "Диагностика" / "доложи о себе" | Состояние всех бэкендов одной строкой |
Всего паков: **59** (`resources/commands/*/command.toml`). Всего паков: **85** (`resources/commands/*/command.toml`) +
плагины пользователя из `%APPDATA%\com.priler.jarvis\plugins\`.
Полный список: GUI → /commands (с поиском и фильтрами). Полный список: GUI → /commands (с поиском и фильтрами).
### Плагины
Голосовые команды, которые лежат **вне** проекта. Каждый плагин —
папка с тем же `command.toml` + Lua-скриптами:
```
%APPDATA%\com.priler.jarvis\plugins\<имя>\
command.toml (тот же формат, что и встроенные паки)
*.lua (скрипты, на которые ссылается toml)
disabled (опционально — пустой файл, отключает плагин)
```
Подгружается при следующем старте `jarvis-app.exe`. Sandbox принудительно
понижается до `standard` — плагин не может попросить `full` (доступ к `os`).
GUI → /plugins показывает список, флажки enable/disable, кнопку «Открыть папку».
### Своё кодовое слово
GUI → /wake-trainer — мастер: запишите 530 примеров своего голоса,
обучите персональную `.rpw`-модель, выберите её в Настройках, перезапустите
ассистент. Файл — `%APPDATA%\com.priler.jarvis\wake_words\<имя>.rpw`.
## Быстрый старт ## Быстрый старт
```powershell ```powershell

View file

@ -9,7 +9,7 @@ use seqdiff::ratio;
mod structs; mod structs;
pub use structs::*; pub use structs::*;
use crate::{config, i18n, APP_DIR}; use crate::{config, i18n, plugins, APP_DIR};
#[cfg(feature = "lua")] #[cfg(feature = "lua")]
use crate::lua::{self, SandboxLevel, CommandContext}; use crate::lua::{self, SandboxLevel, CommandContext};
@ -128,10 +128,22 @@ pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
}); });
} }
// Merge plugin packs from <APP_CONFIG_DIR>/plugins/. Plugin loading is
// best-effort: malformed or missing plugin dirs are logged and skipped, never
// blocking startup. See `plugins.rs` for the layout & sandbox policy.
let plugin_packs = plugins::discover();
let plugin_count = plugin_packs.len();
commands.extend(plugin_packs);
if commands.is_empty() { if commands.is_empty() {
Err("No commands found".into()) Err("No commands found".into())
} else { } else {
info!("Loaded {} command pack(s)", commands.len()); info!(
"Loaded {} command pack(s) total ({} built-in + {} plugin)",
commands.len(),
commands.len() - plugin_count,
plugin_count
);
Ok(commands) Ok(commands)
} }
} }

View file

@ -46,6 +46,12 @@ pub struct Settings {
/// auto-detect. /// auto-detect.
#[serde(default)] #[serde(default)]
pub tts_backend: String, pub tts_backend: String,
/// Name of a custom Rustpotter wake-word model (`.rpw` stem) trained by the
/// user via the wake-word wizard. Empty string means "use the bundled
/// default model". Looked up under `<APP_CONFIG_DIR>/wake_words/<name>.rpw`.
#[serde(default)]
pub custom_wake_word: String,
} }
fn default_intent_backend() -> String { config::DEFAULT_INTENT_BACKEND.to_string() } fn default_intent_backend() -> String { config::DEFAULT_INTENT_BACKEND.to_string() }
@ -75,6 +81,7 @@ impl Settings {
"api_key__openai" => Some(self.api_keys.openai.clone()), "api_key__openai" => Some(self.api_keys.openai.clone()),
"llm_backend" => Some(self.llm_backend.clone()), "llm_backend" => Some(self.llm_backend.clone()),
"tts_backend" => Some(self.tts_backend.clone()), "tts_backend" => Some(self.tts_backend.clone()),
"custom_wake_word" => Some(self.custom_wake_word.clone()),
_ => None, _ => None,
} }
} }
@ -151,6 +158,13 @@ impl Settings {
other => return Err(format!("unknown tts_backend: '{}'", other)), other => return Err(format!("unknown tts_backend: '{}'", other)),
} }
} }
"custom_wake_word" => {
// empty = bundled default; otherwise treated as a file-stem
// under <APP_CONFIG_DIR>/wake_words/<name>.rpw. We accept it
// verbatim — the listener will fall back to the default if the
// file isn't there.
self.custom_wake_word = val.trim().to_string();
}
_ => return Err(format!("unknown setting: '{}'", key)), _ => return Err(format!("unknown setting: '{}'", key)),
} }
Ok(()) Ok(())
@ -175,6 +189,7 @@ impl Settings {
"api_key__openai", "api_key__openai",
"llm_backend", "llm_backend",
"tts_backend", "tts_backend",
"custom_wake_word",
] ]
} }
} }
@ -209,6 +224,7 @@ impl Default for Settings {
llm_backend: String::new(), llm_backend: String::new(),
tts_backend: String::new(), tts_backend: String::new(),
custom_wake_word: String::new(),
} }
} }
} }

View file

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

View file

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

View file

@ -163,3 +163,4 @@ settings-profile = Профіль
header-macros = Макроси header-macros = Макроси
header-scheduler = Розклад header-scheduler = Розклад
header-memory = Пам'ять header-memory = Пам'ять
header-plugins = Плагіни

View file

@ -62,6 +62,10 @@ pub mod scheduler;
pub mod macros; pub mod macros;
pub mod plugins;
pub mod wake_trainer;
#[cfg(feature = "llm")] #[cfg(feature = "llm")]
pub mod llm; pub mod llm;

View file

@ -3,7 +3,7 @@ use std::sync::Mutex;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use rustpotter::Rustpotter; use rustpotter::Rustpotter;
use crate::config; use crate::{config, APP_CONFIG_DIR, DB};
// store rustpotter instance // store rustpotter instance
static RUSTPOTTER: OnceCell<Mutex<Rustpotter>> = OnceCell::new(); static RUSTPOTTER: OnceCell<Mutex<Rustpotter>> = OnceCell::new();
@ -14,24 +14,54 @@ pub fn init() -> Result<(), ()> {
// create rustpotter instance // create rustpotter instance
match Rustpotter::new(&rustpotter_config) { match Rustpotter::new(&rustpotter_config) {
Ok(mut rinstance) => { Ok(mut rinstance) => {
// success // Resolve wake-word file list. Priority:
// wake word files list // 1. user-trained custom model (settings.custom_wake_word)
// @TODO. Make it configurable via GUI for custom user voice. // 2. bundled default
let rustpotter_wake_word_files: [&str; 1] = [ // We always try the bundled default last so the assistant keeps
"resources/rustpotter/jarvis-default.rpw", // working even if the custom model is missing on disk.
// "rustpotter/jarvis-community-1.rpw", let mut loaded_any = false;
// "rustpotter/jarvis-community-2.rpw",
// "rustpotter/jarvis-community-3.rpw",
// "rustpotter/jarvis-community-4.rpw",
// "rustpotter/jarvis-community-5.rpw",
];
// load wake word files if let Some(db) = DB.get() {
for rpw in rustpotter_wake_word_files { let stem = db.read().custom_wake_word.clone();
// @TODO: Change wakeword key to something else? if !stem.is_empty() {
if let Err(e) = rinstance.add_wakeword_from_file(rpw, rpw) { if let Some(cfg_dir) = APP_CONFIG_DIR.get() {
error!("Failed to load wakeword file '{}': {}", rpw, e); 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 // store

View file

@ -0,0 +1,379 @@
//! Plugin system — user-installed command packs that live outside the read-only
//! `resources/commands/` tree.
//!
//! Why: shipping new voice packs as built-ins requires a rebuild + release. End
//! users (and external authors) want to drop a folder into a writable location
//! and have Jarvis pick it up. That's exactly what this module enables.
//!
//! Layout (per pack):
//!
//! ```text
//! <APP_CONFIG_DIR>/plugins/
//! <pack_name>/
//! command.toml (required — same schema as built-in packs)
//! <script>.lua (zero or more scripts referenced from toml)
//! disabled (optional empty file — if present, pack is skipped)
//! ```
//!
//! On Windows this resolves to `%APPDATA%\com.priler.jarvis\plugins\`.
//!
//! Security: plugin Lua scripts run under the same sandbox the `command.toml`
//! declares, but we cap the maximum sandbox level at `Standard`. Plugin authors
//! cannot reach `Full` (which gives `os` access). This is the only place where
//! a plugin pack differs from a built-in pack.
//!
//! Discovery is read-only and tolerant: a malformed `command.toml` logs a
//! warning and is skipped; it never poisons the whole list.
//!
//! Reload: `commands::reload_global()` rescans both built-in and plugin packs
//! and atomically swaps `COMMANDS_LIST`. The jarvis-app FS watcher fires this
//! on any change under `plugins/`.
use std::fs;
use std::path::{Path, PathBuf};
use crate::commands::JCommandsList;
use crate::APP_CONFIG_DIR;
const PLUGINS_DIR_NAME: &str = "plugins";
const DISABLED_FLAG: &str = "disabled";
const MAX_SANDBOX_FOR_PLUGINS: &str = "standard";
/// Metadata about a single plugin pack on disk.
#[derive(Debug, Clone, serde::Serialize)]
pub struct PluginInfo {
pub name: String,
pub path: PathBuf,
pub enabled: bool,
pub command_count: usize,
pub error: Option<String>,
}
/// Returns the on-disk plugins directory (`<APP_CONFIG_DIR>/plugins`).
/// Creates it on first call so the user can find and populate it.
pub fn plugins_dir() -> Result<PathBuf, String> {
let cfg = APP_CONFIG_DIR
.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let dir = cfg.join(PLUGINS_DIR_NAME);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("create plugins dir: {}", e))?;
}
Ok(dir)
}
/// Walk the plugins directory and parse every `command.toml`.
/// Returns parsed packs; malformed packs are logged and skipped.
pub fn discover() -> Vec<JCommandsList> {
let dir = match plugins_dir() {
Ok(d) => d,
Err(e) => {
warn!("Plugins discovery skipped: {}", e);
return Vec::new();
}
};
discover_in(&dir)
}
/// Same as `discover()` but takes an explicit directory — used by tests.
pub fn discover_in(dir: &Path) -> Vec<JCommandsList> {
let mut out = Vec::new();
let read = match fs::read_dir(dir) {
Ok(r) => r,
Err(e) => {
// not a hard error — directory may not exist yet
debug!("Plugins dir unreadable {}: {}", dir.display(), e);
return out;
}
};
for entry in read.flatten() {
let pack_path = entry.path();
if !pack_path.is_dir() {
continue;
}
if pack_path.join(DISABLED_FLAG).is_file() {
debug!("Plugin '{}' is disabled, skipping", pack_path.display());
continue;
}
let toml_file = pack_path.join("command.toml");
if !toml_file.is_file() {
continue;
}
match parse_one(&pack_path, &toml_file) {
Ok(list) => out.push(list),
Err(e) => warn!("Plugin '{}' load failed: {}", pack_path.display(), e),
}
}
if !out.is_empty() {
info!(
"Loaded {} plugin pack(s) from {}",
out.len(),
dir.display()
);
}
out
}
fn parse_one(pack_path: &Path, toml_file: &Path) -> Result<JCommandsList, String> {
let body = fs::read_to_string(toml_file).map_err(|e| format!("read: {}", e))?;
let mut pack: JCommandsList =
toml::from_str(&body).map_err(|e| format!("parse: {}", e))?;
// enforce sandbox cap and validate script references
for cmd in &mut pack.commands {
if cmd.cmd_type == "lua" {
let requested = if cmd.sandbox.is_empty() {
MAX_SANDBOX_FOR_PLUGINS
} else {
cmd.sandbox.as_str()
};
if requested == "full" {
warn!(
"Plugin '{}': command '{}' requested 'full' sandbox; \
downgrading to 'standard' (plugin policy)",
pack_path.display(),
cmd.id
);
cmd.sandbox = MAX_SANDBOX_FOR_PLUGINS.to_string();
} else if cmd.sandbox.is_empty() {
cmd.sandbox = MAX_SANDBOX_FOR_PLUGINS.to_string();
}
if !cmd.script.is_empty() {
let s = pack_path.join(&cmd.script);
if !s.is_file() {
return Err(format!(
"command '{}' references missing script: {}",
cmd.id,
s.display()
));
}
}
}
}
pack.path = pack_path.to_path_buf();
Ok(pack)
}
/// Lightweight listing for the GUI. Reports each pack regardless of enable state.
pub fn list() -> Vec<PluginInfo> {
let dir = match plugins_dir() {
Ok(d) => d,
Err(_) => return Vec::new(),
};
list_in(&dir)
}
pub fn list_in(dir: &Path) -> Vec<PluginInfo> {
let mut out = Vec::new();
let read = match fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return out,
};
for entry in read.flatten() {
let pack_path = entry.path();
if !pack_path.is_dir() {
continue;
}
let toml_file = pack_path.join("command.toml");
if !toml_file.is_file() {
continue;
}
let name = pack_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let enabled = !pack_path.join(DISABLED_FLAG).is_file();
let (count, error) = match parse_one(&pack_path, &toml_file) {
Ok(p) => (p.commands.len(), None),
Err(e) => (0, Some(e)),
};
out.push(PluginInfo {
name,
path: pack_path,
enabled,
command_count: count,
error,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Touch `disabled` flag inside the pack folder so the next discovery skips it.
pub fn set_enabled(name: &str, enabled: bool) -> Result<(), String> {
let dir = plugins_dir()?;
let pack = dir.join(name);
if !pack.is_dir() {
return Err(format!("plugin '{}' not found", name));
}
let flag = pack.join(DISABLED_FLAG);
if enabled {
if flag.is_file() {
fs::remove_file(&flag).map_err(|e| format!("remove flag: {}", e))?;
}
} else if !flag.is_file() {
fs::write(&flag, b"").map_err(|e| format!("write flag: {}", e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(path: &Path, content: &str) {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).unwrap();
}
std::fs::write(path, content).unwrap();
}
fn good_toml() -> &'static str {
r#"
[[commands]]
id = "my_plugin.hello"
type = "lua"
script = "hello.lua"
sandbox = "standard"
timeout = 3000
[commands.phrases]
ru = ["привет плагин"]
"#
}
#[test]
fn discovers_packs_with_command_toml() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("greeter");
write(&pack.join("command.toml"), good_toml());
write(&pack.join("hello.lua"), "return jarvis.cmd.ok('hi')");
let packs = discover_in(dir.path());
assert_eq!(packs.len(), 1, "expected exactly one pack");
assert_eq!(packs[0].commands.len(), 1);
assert_eq!(packs[0].commands[0].id, "my_plugin.hello");
assert_eq!(packs[0].path, pack);
}
#[test]
fn disabled_flag_skips_pack() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("greeter");
write(&pack.join("command.toml"), good_toml());
write(&pack.join("hello.lua"), "return jarvis.cmd.ok('hi')");
write(&pack.join("disabled"), "");
let packs = discover_in(dir.path());
assert!(packs.is_empty(), "disabled pack must be skipped");
}
#[test]
fn missing_script_is_an_error() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("broken");
write(&pack.join("command.toml"), good_toml());
// intentionally no hello.lua
let packs = discover_in(dir.path());
assert!(packs.is_empty(), "pack with missing script must not load");
}
#[test]
fn malformed_toml_is_skipped_not_fatal() {
let dir = TempDir::new().unwrap();
let pack_bad = dir.path().join("bad");
write(&pack_bad.join("command.toml"), "this is not valid toml ::::");
let pack_good = dir.path().join("good");
write(&pack_good.join("command.toml"), good_toml());
write(&pack_good.join("hello.lua"), "return jarvis.cmd.ok('hi')");
let packs = discover_in(dir.path());
assert_eq!(packs.len(), 1, "good pack should still load despite bad sibling");
}
#[test]
fn full_sandbox_is_downgraded_to_standard() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("evil");
let toml = r#"
[[commands]]
id = "evil.run"
type = "lua"
script = "evil.lua"
sandbox = "full"
timeout = 1000
[commands.phrases]
ru = ["взломай систему"]
"#;
write(&pack.join("command.toml"), toml);
write(&pack.join("evil.lua"), "return jarvis.cmd.ok('nope')");
let packs = discover_in(dir.path());
assert_eq!(packs.len(), 1);
assert_eq!(
packs[0].commands[0].sandbox, "standard",
"plugin must not be allowed to escalate to full sandbox"
);
}
#[test]
fn empty_sandbox_defaults_to_standard() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("plain");
let toml = r#"
[[commands]]
id = "plain.go"
type = "lua"
script = "go.lua"
timeout = 1000
[commands.phrases]
ru = ["иди"]
"#;
write(&pack.join("command.toml"), toml);
write(&pack.join("go.lua"), "return jarvis.cmd.ok('ok')");
let packs = discover_in(dir.path());
assert_eq!(packs.len(), 1);
assert_eq!(packs[0].commands[0].sandbox, "standard");
}
#[test]
fn list_reports_enabled_and_disabled() {
let dir = TempDir::new().unwrap();
let a = dir.path().join("alpha");
write(&a.join("command.toml"), good_toml());
write(&a.join("hello.lua"), "return jarvis.cmd.ok('hi')");
let b = dir.path().join("bravo");
write(&b.join("command.toml"), good_toml());
write(&b.join("hello.lua"), "return jarvis.cmd.ok('hi')");
write(&b.join("disabled"), "");
let listing = list_in(dir.path());
assert_eq!(listing.len(), 2);
let alpha = listing.iter().find(|p| p.name == "alpha").unwrap();
let bravo = listing.iter().find(|p| p.name == "bravo").unwrap();
assert!(alpha.enabled);
assert!(!bravo.enabled);
assert_eq!(alpha.command_count, 1);
}
#[test]
fn non_directories_are_ignored() {
let dir = TempDir::new().unwrap();
write(&dir.path().join("README.md"), "# hi");
write(&dir.path().join("strayfile.toml"), good_toml());
let packs = discover_in(dir.path());
assert!(packs.is_empty());
}
}

View file

@ -0,0 +1,439 @@
//! Custom wake-word trainer — guides the user through recording a handful of
//! samples of their chosen wake phrase, then trains a Rustpotter `.rpw` model
//! that can replace the bundled `jarvis-default.rpw`.
//!
//! Flow (driven by `jarvis-gui` over Tauri commands):
//!
//! ```text
//! start(name, target=10)
//! -> session entered; mic opened (exclusive); state = Recording { ... }
//! record_sample(seconds=1.5) ×N
//! -> blocks for `seconds`, captures i16 samples, appends to buffer
//! -> WAV-encodes each clip in memory so rustpotter can consume it
//! finish(threshold=0.5)
//! -> calls rustpotter's WakewordRef::new_from_sample_buffers
//! -> serialises .rpw into <APP_CONFIG_DIR>/wake_words/<name>.rpw
//! -> mic released; state = Idle
//! cancel()
//! -> mic released; state = Idle, samples discarded
//! ```
//!
//! Concurrency: the trainer holds its own `pv_recorder` instance so it doesn't
//! collide with the `recorder` module that jarvis-app uses. pv_recorder DOES
//! claim the device exclusively, so the trainer refuses to start if jarvis-app
//! is recording.
//!
//! Output layout:
//! - `<APP_CONFIG_DIR>/wake_words/<name>.rpw` — the trained model
//! - `<APP_CONFIG_DIR>/wake_words/<name>/sample_NN.wav` — keepsake samples,
//! useful if the user wants to retrain with different parameters later
//!
//! Selecting which `.rpw` is active is handled by the settings page (radio
//! between bundled default and any custom models discovered here).
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use rustpotter::{WakewordRef, WakewordRefBuildFromBuffers, WakewordSave};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::APP_CONFIG_DIR;
const WAKE_WORDS_DIR_NAME: &str = "wake_words";
const SAMPLE_RATE: u32 = 16_000;
const FRAME_LEN: i32 = 512;
const MFCC_SIZE: u16 = 16;
pub const DEFAULT_SAMPLE_SECONDS: f32 = 1.5;
pub const DEFAULT_TARGET_SAMPLES: u8 = 10;
pub const MIN_TARGET_SAMPLES: u8 = 5;
pub const MAX_TARGET_SAMPLES: u8 = 30;
#[derive(Debug, Clone, serde::Serialize)]
pub struct TrainerStatus {
pub recording: bool,
pub session_name: Option<String>,
pub target_samples: u8,
pub collected: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TrainedModelInfo {
pub name: String,
pub path: String,
pub size_bytes: u64,
pub modified: i64,
}
struct Session {
name: String,
target_samples: u8,
// each sample is a WAV-encoded byte buffer (16-bit PCM, mono, 16kHz)
samples_wav: Vec<Vec<u8>>,
// raw i16 samples kept around so we can also dump .wav keepsakes to disk
samples_pcm: Vec<Vec<i16>>,
recorder: Option<pv_recorder::PvRecorder>,
}
struct State {
session: Mutex<Option<Session>>,
}
static STATE: OnceCell<State> = OnceCell::new();
fn state() -> &'static State {
STATE.get_or_init(|| State {
session: Mutex::new(None),
})
}
/// Returns `<APP_CONFIG_DIR>/wake_words/`, creating it on first call.
pub fn models_dir() -> Result<PathBuf, String> {
let cfg = APP_CONFIG_DIR
.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let dir = cfg.join(WAKE_WORDS_DIR_NAME);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("create wake_words dir: {}", e))?;
}
Ok(dir)
}
pub fn status() -> TrainerStatus {
let s = state().session.lock();
if let Some(sess) = s.as_ref() {
TrainerStatus {
recording: true,
session_name: Some(sess.name.clone()),
target_samples: sess.target_samples,
collected: sess.samples_wav.len(),
}
} else {
TrainerStatus {
recording: false,
session_name: None,
target_samples: DEFAULT_TARGET_SAMPLES,
collected: 0,
}
}
}
/// Open the mic and enter a new training session. Returns Err if a session
/// already exists or the device is unavailable (e.g. jarvis-app holds it).
pub fn start(name: &str, target_samples: u8) -> Result<(), String> {
let clean_name = sanitize(name);
if clean_name.is_empty() {
return Err("Имя пустое или содержит только запрещённые символы".to_string());
}
let target = target_samples.clamp(MIN_TARGET_SAMPLES, MAX_TARGET_SAMPLES);
let mut slot = state().session.lock();
if slot.is_some() {
return Err("Сессия записи уже активна".to_string());
}
let recorder = pv_recorder::PvRecorderBuilder::new(FRAME_LEN)
.device_index(-1) // default device
.init()
.map_err(|e| {
format!(
"Не удалось открыть микрофон ({:?}). Сначала остановите J.A.R.V.I.S.",
e
)
})?;
recorder
.start()
.map_err(|e| format!("Микрофон отказал в записи: {}", e))?;
*slot = Some(Session {
name: clean_name,
target_samples: target,
samples_wav: Vec::with_capacity(target as usize),
samples_pcm: Vec::with_capacity(target as usize),
recorder: Some(recorder),
});
Ok(())
}
/// Capture one fixed-duration sample. Blocks for `seconds` (clamped to 0.5..5.0).
pub fn record_sample(seconds: f32) -> Result<usize, String> {
let dur = seconds.clamp(0.5, 5.0);
let mut slot = state().session.lock();
let sess = slot
.as_mut()
.ok_or_else(|| "Сессия не запущена".to_string())?;
if sess.samples_wav.len() >= sess.target_samples as usize {
return Err("Все сэмплы уже записаны — нажмите 'Сохранить'".to_string());
}
let recorder = sess
.recorder
.as_ref()
.ok_or_else(|| "Микрофон не инициализирован".to_string())?;
let total_samples = (SAMPLE_RATE as f32 * dur) as usize;
let mut pcm: Vec<i16> = Vec::with_capacity(total_samples + FRAME_LEN as usize);
let deadline = Instant::now() + Duration::from_millis((dur * 1000.0) as u64 + 50);
while pcm.len() < total_samples {
if Instant::now() > deadline {
// hard cap — shouldn't normally trigger but guards against driver hang
break;
}
match recorder.read() {
Ok(frame) => pcm.extend_from_slice(&frame),
Err(e) => return Err(format!("Чтение микрофона: {}", e)),
}
}
pcm.truncate(total_samples);
let wav = encode_wav(&pcm)
.map_err(|e| format!("Не удалось закодировать WAV: {}", e))?;
sess.samples_wav.push(wav);
sess.samples_pcm.push(pcm);
Ok(sess.samples_wav.len())
}
/// Train the model + persist .rpw and keepsake WAV files. Returns the path
/// to the new `.rpw`. Session is cleared on success regardless.
pub fn finish(threshold: f32) -> Result<PathBuf, String> {
let mut slot = state().session.lock();
let sess = slot
.take()
.ok_or_else(|| "Сессия не запущена".to_string())?;
if let Some(rec) = sess.recorder {
let _ = rec.stop();
}
if sess.samples_wav.len() < MIN_TARGET_SAMPLES as usize {
return Err(format!(
"Нужно минимум {} сэмплов, у тебя {}",
MIN_TARGET_SAMPLES,
sess.samples_wav.len()
));
}
let dir = models_dir()?;
let model_path = dir.join(format!("{}.rpw", sess.name));
let samples_dir = dir.join(&sess.name);
if !samples_dir.exists() {
fs::create_dir_all(&samples_dir)
.map_err(|e| format!("создать папку сэмплов: {}", e))?;
}
// dump keepsake WAVs so the user (or a future retrain) can reuse them
for (i, pcm) in sess.samples_pcm.iter().enumerate() {
let path = samples_dir.join(format!("sample_{:02}.wav", i + 1));
if let Ok(bytes) = encode_wav(pcm) {
let _ = fs::write(path, bytes);
}
}
// build the in-memory sample map rustpotter wants (file_name -> wav bytes)
let mut samples_map: HashMap<String, Vec<u8>> = HashMap::with_capacity(sess.samples_wav.len());
for (i, wav) in sess.samples_wav.into_iter().enumerate() {
samples_map.insert(format!("sample_{:02}.wav", i + 1), wav);
}
let threshold = threshold.clamp(0.3, 0.9);
let model = WakewordRef::new_from_sample_buffers(
sess.name.clone(),
Some(threshold),
None,
samples_map,
MFCC_SIZE,
)
.map_err(|e| format!("rustpotter обучение: {}", e))?;
let path_str = model_path
.to_str()
.ok_or_else(|| "Путь содержит не-UTF8".to_string())?;
model
.save_to_file(path_str)
.map_err(|e| format!("сохранение модели: {}", e))?;
info!(
"Wake-word model trained and saved: {} (threshold {:.2})",
model_path.display(),
threshold
);
Ok(model_path)
}
/// Abort the current session, releasing the mic. Always succeeds.
pub fn cancel() {
let mut slot = state().session.lock();
if let Some(sess) = slot.take() {
if let Some(rec) = sess.recorder {
let _ = rec.stop();
}
}
}
/// List all `.rpw` models the user has trained so far.
pub fn list_models() -> Vec<TrainedModelInfo> {
let dir = match models_dir() {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let read = match fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for entry in read.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("rpw") {
continue;
}
let name = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let meta = match path.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
out.push(TrainedModelInfo {
name,
path: path.display().to_string(),
size_bytes: meta.len(),
modified,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Delete a previously trained model + its keepsake samples.
pub fn delete_model(name: &str) -> Result<(), String> {
let clean = sanitize(name);
if clean.is_empty() {
return Err("имя пустое".to_string());
}
let dir = models_dir()?;
let rpw = dir.join(format!("{}.rpw", clean));
let samples = dir.join(&clean);
if rpw.is_file() {
fs::remove_file(&rpw).map_err(|e| format!("rm rpw: {}", e))?;
}
if samples.is_dir() {
fs::remove_dir_all(&samples).map_err(|e| format!("rm samples: {}", e))?;
}
Ok(())
}
fn sanitize(raw: &str) -> String {
// strip path separators + control chars; keep ascii alnum, underscore, dash, dot
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
c
} else if c.is_alphabetic() {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.trim_matches('.')
.to_string()
}
fn encode_wav(pcm: &[i16]) -> Result<Vec<u8>, String> {
use std::io::{Cursor, Write};
let mut buf: Vec<u8> = Vec::with_capacity(44 + pcm.len() * 2);
let mut cursor = Cursor::new(&mut buf);
let byte_rate: u32 = SAMPLE_RATE * 2; // mono * 16-bit
let data_size: u32 = (pcm.len() * 2) as u32;
let chunk_size: u32 = 36 + data_size;
cursor.write_all(b"RIFF").map_err(|e| e.to_string())?;
cursor.write_all(&chunk_size.to_le_bytes()).map_err(|e| e.to_string())?;
cursor.write_all(b"WAVE").map_err(|e| e.to_string())?;
cursor.write_all(b"fmt ").map_err(|e| e.to_string())?;
cursor.write_all(&16u32.to_le_bytes()).map_err(|e| e.to_string())?; // PCM subchunk size
cursor.write_all(&1u16.to_le_bytes()).map_err(|e| e.to_string())?; // PCM format
cursor.write_all(&1u16.to_le_bytes()).map_err(|e| e.to_string())?; // channels
cursor.write_all(&SAMPLE_RATE.to_le_bytes()).map_err(|e| e.to_string())?;
cursor.write_all(&byte_rate.to_le_bytes()).map_err(|e| e.to_string())?;
cursor.write_all(&2u16.to_le_bytes()).map_err(|e| e.to_string())?; // block align
cursor.write_all(&16u16.to_le_bytes()).map_err(|e| e.to_string())?; // bits per sample
cursor.write_all(b"data").map_err(|e| e.to_string())?;
cursor.write_all(&data_size.to_le_bytes()).map_err(|e| e.to_string())?;
for s in pcm {
cursor.write_all(&s.to_le_bytes()).map_err(|e| e.to_string())?;
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_path_chars() {
// leading dots/underscores are also trimmed for safety
assert_eq!(sanitize("../etc/passwd"), "_etc_passwd");
assert_eq!(sanitize("my-wake"), "my-wake");
assert_eq!(sanitize("hello world"), "hello_world");
assert_eq!(sanitize("Привет.Мир"), "Привет.Мир");
assert_eq!(sanitize("___"), "");
// ensure result never starts with a dot — guards against hidden files
// and against traversal: even "..\..\foo" can't escape its directory
assert!(!sanitize("..\\..\\foo").starts_with('.'));
}
#[test]
fn wav_encoding_round_trip_size_check() {
let pcm: Vec<i16> = (0..1600).map(|i| (i % 100) as i16).collect();
let wav = encode_wav(&pcm).expect("encode wav");
// 44-byte RIFF header + 2 bytes per sample
assert_eq!(wav.len(), 44 + pcm.len() * 2);
assert_eq!(&wav[0..4], b"RIFF");
assert_eq!(&wav[8..12], b"WAVE");
assert_eq!(&wav[12..16], b"fmt ");
assert_eq!(&wav[36..40], b"data");
// sample rate at offset 24, little-endian
let sr = u32::from_le_bytes([wav[24], wav[25], wav[26], wav[27]]);
assert_eq!(sr, SAMPLE_RATE);
}
#[test]
fn idle_status_when_no_session() {
// ensure STATE init doesn't panic and reports recording=false
let s = status();
assert!(!s.recording);
assert_eq!(s.collected, 0);
}
#[test]
fn cancel_when_idle_is_a_noop() {
cancel();
// no panic, status remains idle
let s = status();
assert!(!s.recording);
}
#[test]
fn record_sample_without_session_errors() {
let r = record_sample(1.0);
assert!(r.is_err(), "must error when no session is active");
}
}

View file

@ -133,6 +133,21 @@ fn main() {
tauri_commands::profile_list, tauri_commands::profile_list,
tauri_commands::profile_active, tauri_commands::profile_active,
tauri_commands::profile_set, tauri_commands::profile_set,
// User plugins
tauri_commands::plugins_list,
tauri_commands::plugins_set_enabled,
tauri_commands::plugins_open_folder,
// Wake-word trainer wizard
tauri_commands::wake_trainer_status,
tauri_commands::wake_trainer_defaults,
tauri_commands::wake_trainer_start,
tauri_commands::wake_trainer_record_sample,
tauri_commands::wake_trainer_finish,
tauri_commands::wake_trainer_cancel,
tauri_commands::wake_trainer_list_models,
tauri_commands::wake_trainer_delete_model,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -58,3 +58,11 @@ pub use memory::*;
// Profile switching // Profile switching
mod profile; mod profile;
pub use profile::*; pub use profile::*;
// User-installed plugins (APP_CONFIG_DIR/plugins/<name>/)
mod plugins;
pub use plugins::*;
// Wake-word training wizard
mod wake_trainer;
pub use wake_trainer::*;

View file

@ -0,0 +1,47 @@
//! Tauri commands for the plugin system. GUI uses these to render
//! `/plugins` page: list installed plugins, toggle enabled state, reveal the
//! plugins folder in Explorer, and open the docs page.
use serde::Serialize;
use std::path::PathBuf;
#[derive(Serialize)]
pub struct PluginEntry {
pub name: String,
pub path: String,
pub enabled: bool,
pub command_count: usize,
pub error: Option<String>,
}
#[tauri::command]
pub fn plugins_list() -> Vec<PluginEntry> {
jarvis_core::plugins::list()
.into_iter()
.map(|p| PluginEntry {
name: p.name,
path: p.path.display().to_string(),
enabled: p.enabled,
command_count: p.command_count,
error: p.error,
})
.collect()
}
#[tauri::command]
pub fn plugins_set_enabled(name: String, enabled: bool) -> Result<(), String> {
jarvis_core::plugins::set_enabled(&name, enabled)
}
#[tauri::command]
pub fn plugins_open_folder() -> Result<String, String> {
let dir: PathBuf = jarvis_core::plugins::plugins_dir()?;
// Open the folder in Explorer (Windows-only — fall back to printing path).
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("explorer.exe")
.arg(&dir)
.spawn();
}
Ok(dir.display().to_string())
}

View file

@ -0,0 +1,50 @@
//! Tauri commands for the wake-word trainer wizard. The GUI drives the
//! whole flow over these — see `frontend/src/routes/wake-trainer/index.svelte`.
//!
//! Key safety note: pv_recorder claims the mic exclusively, so the wizard
//! refuses to enter recording mode while jarvis-app.exe is running. The frontend
//! also checks this beforehand and prompts the user to stop the daemon.
use jarvis_core::wake_trainer::{
self, TrainedModelInfo, TrainerStatus, DEFAULT_SAMPLE_SECONDS, DEFAULT_TARGET_SAMPLES,
};
#[tauri::command]
pub fn wake_trainer_status() -> TrainerStatus {
wake_trainer::status()
}
#[tauri::command]
pub fn wake_trainer_defaults() -> (f32, u8) {
(DEFAULT_SAMPLE_SECONDS, DEFAULT_TARGET_SAMPLES)
}
#[tauri::command]
pub fn wake_trainer_start(name: String, target_samples: u8) -> Result<(), String> {
wake_trainer::start(&name, target_samples)
}
#[tauri::command]
pub fn wake_trainer_record_sample(seconds: f32) -> Result<usize, String> {
wake_trainer::record_sample(seconds)
}
#[tauri::command]
pub fn wake_trainer_finish(threshold: f32) -> Result<String, String> {
wake_trainer::finish(threshold).map(|p| p.display().to_string())
}
#[tauri::command]
pub fn wake_trainer_cancel() {
wake_trainer::cancel()
}
#[tauri::command]
pub fn wake_trainer_list_models() -> Vec<TrainedModelInfo> {
wake_trainer::list_models()
}
#[tauri::command]
pub fn wake_trainer_delete_model(name: String) -> Result<(), String> {
wake_trainer::delete_model(&name)
}

View file

@ -183,6 +183,10 @@
<span class="btn-text">{t('header-memory') || 'Память'}</span> <span class="btn-text">{t('header-memory') || 'Память'}</span>
</button> </button>
<button class="header-btn" on:click={() => $goto('/plugins')} title="User plugins">
<span class="btn-text">{t('header-plugins') || 'Плагины'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/settings')}> <button class="header-btn" on:click={() => $goto('/settings')}>
<span class="btn-text">{t('header-settings')}</span> <span class="btn-text">{t('header-settings')}</span>
</button> </button>

View file

@ -0,0 +1,237 @@
<script lang="ts">
import { onMount } 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, Switch,
} from "@svelteuidev/core"
import { CrossCircled, ExternalLink, ReloadIcon } from "radix-icons-svelte"
interface PluginEntry {
name: string
path: string
enabled: boolean
command_count: number
error: string | null
}
let plugins: PluginEntry[] = []
let loading = true
let error = ""
let info = ""
let busy = ""
async function load() {
loading = true
error = ""
try {
plugins = await invoke<PluginEntry[]>("plugins_list")
plugins = [...plugins].sort((a, b) => a.name.localeCompare(b.name))
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function toggle(p: PluginEntry) {
busy = p.name
try {
await invoke("plugins_set_enabled", { name: p.name, enabled: !p.enabled })
await load()
info = `«${p.name}» ${p.enabled ? "выключен" : "включён"}. Перезапусти Jarvis, чтобы применить.`
setTimeout(() => { info = "" }, 5000)
} catch (e) {
error = String(e)
} finally {
busy = ""
}
}
async function openFolder() {
try {
const path = await invoke<string>("plugins_open_folder")
info = `Папка плагинов: ${path}`
setTimeout(() => { info = "" }, 4000)
} catch (e) {
error = String(e)
}
}
onMount(load)
</script>
<Space h="xl" />
<h2 class="page-title">Плагины</h2>
<Text size="sm" color="gray">
Дополнительные voice-команды, которые лежат рядом с конфигом
(<code>%APPDATA%\com.priler.jarvis\plugins\</code>). Каждый плагин — это
папка с <code>command.toml</code> и Lua-скриптами.
Меняется только после перезапуска.
</Text>
<Space h="md" />
<div class="actions-row">
<Button color="lime" radius="md" size="sm" on:click={openFolder}>
<ExternalLink size={14} /> &nbsp; Открыть папку
</Button>
&nbsp;
<Button color="gray" radius="md" size="sm" on:click={load}>
<ReloadIcon size={14} /> &nbsp; Обновить список
</Button>
</div>
<Space h="md" />
{#if info}
<Notification title="Готово" color="teal" on:close={() => { info = "" }}>
{info}
</Notification>
<Space h="sm" />
{/if}
{#if error}
<Notification
title="Ошибка"
icon={CrossCircled}
color="red"
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if plugins.length === 0}
<Text size="sm" color="gray">
Плагинов пока нет. Скопируйте папку плагина в указанный каталог
и нажмите «Обновить список».
</Text>
{:else}
<div class="plugin-list">
{#each plugins as p}
<div class="plugin-card" class:disabled={!p.enabled} class:errored={!!p.error}>
<div class="plugin-header">
<span class="plugin-name">{p.name}</span>
{#if p.error}
<Badge color="red" variant="filled" size="sm">ошибка</Badge>
{:else}
<Badge color={p.enabled ? "lime" : "gray"} variant="filled" size="sm">
{p.command_count} команд
</Badge>
{/if}
</div>
<div class="plugin-path">{p.path}</div>
{#if p.error}
<div class="plugin-error">{p.error}</div>
{/if}
<div class="plugin-actions">
<Switch
size="sm"
checked={p.enabled}
disabled={busy === p.name || !!p.error}
on:change={() => toggle(p)}
/>
<Text size="xs" color={p.enabled ? "lime" : "gray"}>
{p.enabled ? "Включён" : "Выключен"}
</Text>
</div>
</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;
}
code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.8rem;
}
.actions-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.plugin-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.plugin-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.85rem 1rem;
&.disabled {
opacity: 0.55;
}
&.errored {
border-color: rgba(220, 90, 90, 0.5);
}
}
.plugin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.plugin-name {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.plugin-path {
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
font-family: ui-monospace, "Cascadia Mono", monospace;
word-break: break-all;
margin-bottom: 0.5rem;
}
.plugin-error {
color: rgba(255, 120, 120, 0.95);
background: rgba(120, 30, 30, 0.25);
font-size: 0.75rem;
padding: 0.35rem 0.55rem;
border-radius: 6px;
margin-bottom: 0.5rem;
}
.plugin-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,334 @@
<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, NumberInput, Slider,
} from "@svelteuidev/core"
import { CrossCircled, Microphone2, TrashIcon, Check, ResumeIcon } from "radix-icons-svelte"
interface TrainerStatus {
recording: boolean
session_name: string | null
target_samples: number
collected: number
}
interface TrainedModel {
name: string
path: string
size_bytes: number
modified: number
}
let status: TrainerStatus = { recording: false, session_name: null, target_samples: 10, collected: 0 }
let models: TrainedModel[] = []
let loading = true
let error = ""
let info = ""
let busy = false
let newName = ""
let targetSamples = 10
let sampleSeconds = 1.5
let threshold = 0.5
let savedModelPath = ""
let daemonRunning = false
async function load() {
loading = true
error = ""
try {
const [s, m, run] = await Promise.all([
invoke<TrainerStatus>("wake_trainer_status"),
invoke<TrainedModel[]>("wake_trainer_list_models"),
invoke<boolean>("is_jarvis_app_running"),
])
status = s
models = [...m].sort((a, b) => a.name.localeCompare(b.name))
daemonRunning = run
if (!status.recording) {
targetSamples = status.target_samples || 10
}
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function startSession() {
const name = newName.trim()
if (!name) { error = "Введите имя модели"; return }
if (daemonRunning) {
error = "Сначала остановите jarvis-app — он держит микрофон."
return
}
busy = true
try {
await invoke("wake_trainer_start", { name, targetSamples })
await load()
info = `Сессия запущена. Сэмплов нужно: ${targetSamples}.`
} catch (e) { error = String(e) }
finally { busy = false }
}
async function recordOne() {
busy = true
try {
await invoke("wake_trainer_record_sample", { seconds: sampleSeconds })
await load()
info = `Записано: ${status.collected} / ${status.target_samples}`
} catch (e) { error = String(e) }
finally { busy = false }
}
async function finish() {
busy = true
try {
const path = await invoke<string>("wake_trainer_finish", { threshold })
savedModelPath = path
info = `Модель сохранена: ${path}. Перейдите в Настройки и выберите её, потом перезапустите Jarvis.`
await load()
} catch (e) { error = String(e) }
finally { busy = false }
}
async function cancel() {
busy = true
try {
await invoke("wake_trainer_cancel")
await load()
info = "Сессия отменена."
} catch (e) { error = String(e) }
finally { busy = false }
}
async function deleteModel(name: string) {
if (!confirm(`Удалить модель "${name}" и её сэмплы?`)) return
try {
await invoke("wake_trainer_delete_model", { name })
await load()
} catch (e) { error = String(e) }
}
let poll: ReturnType<typeof setInterval> | null = null
onMount(() => {
load()
poll = setInterval(load, 3000)
})
onDestroy(() => {
if (poll !== null) clearInterval(poll)
})
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(2)} MB`
}
function fmtDate(ts: number): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString()
}
</script>
<Space h="xl" />
<h2 class="page-title">Своё кодовое слово</h2>
<Text size="sm" color="gray">
Запишите 530 примеров своего голоса, и Jarvis обучит персональный
Rustpotter-детектор. Файл сохраняется как
<code>%APPDATA%\com.priler.jarvis\wake_words\&lt;имя&gt;.rpw</code>.
Активная модель выбирается в Настройках.
</Text>
<Space h="md" />
{#if daemonRunning}
<Notification title="Jarvis запущен" color="orange" withCloseButton={false}>
Остановите jarvis-app (через трей или кнопку «Стоп») — иначе микрофон
будет занят и запись не пойдёт.
</Notification>
<Space h="sm" />
{/if}
{#if info}
<Notification title="Готово" color="teal" icon={Check} on:close={() => { info = "" }}>
{info}
</Notification>
<Space h="sm" />
{/if}
{#if error}
<Notification title="Ошибка" color="red" icon={CrossCircled} on:close={() => { error = "" }}>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if status.recording}
<div class="session-card">
<div class="row spaced">
<div>
<strong>Запись:</strong>
<code>{status.session_name}</code>
</div>
<Badge color="lime" variant="filled">
{status.collected} / {status.target_samples}
</Badge>
</div>
<Space h="sm" />
<Text size="xs" color="gray">
Произнеси своё кодовое слово (например, «Джарвис») чётко, по очереди,
одинаково. Длина каждого сэмпла — {sampleSeconds.toFixed(1)} сек.
</Text>
<Space h="sm" />
<div class="row">
<Text size="xs">Длина сэмпла: {sampleSeconds.toFixed(1)} сек</Text>
<Slider min={0.8} max={3.0} step={0.1} bind:value={sampleSeconds} disabled={busy} />
</div>
<Space h="sm" />
<Button color="lime" radius="md" size="sm" disabled={busy} on:click={recordOne}>
<Microphone2 size={14} /> &nbsp;
{busy ? "Запись..." : `Записать сэмпл (${status.collected + 1})`}
</Button>
&nbsp;
<Button color="gray" radius="md" size="sm" disabled={busy} on:click={cancel}>
Отменить
</Button>
{#if status.collected >= 5}
<Space h="md" />
<div class="row">
<Text size="xs">Порог детектора: {threshold.toFixed(2)}</Text>
<Slider min={0.30} max={0.90} step={0.05} bind:value={threshold} disabled={busy} />
</div>
<Text size="xs" color="gray">
Ниже — больше срабатываний (и ложных). Выше — пропусков больше.
</Text>
<Space h="sm" />
<Button color="teal" radius="md" size="sm" disabled={busy} on:click={finish}>
<Check size={14} /> &nbsp; Обучить и сохранить
</Button>
{/if}
</div>
{:else}
<div class="form-card">
<TextInput
label="Имя модели"
description="Только латиница / кириллица + цифры. Пробелы заменятся на _"
placeholder="например: my_jarvis"
bind:value={newName}
disabled={busy}
/>
<Space h="sm" />
<NumberInput
label="Сколько сэмплов записать"
description="5 минимум, 1015 рекомендовано"
min={5}
max={30}
bind:value={targetSamples}
disabled={busy}
/>
<Space h="sm" />
<Button color="lime" radius="md" size="sm" disabled={busy || daemonRunning} on:click={startSession}>
<ResumeIcon size={14} /> &nbsp; Начать запись
</Button>
</div>
{/if}
<Space h="xl" />
<h3 class="section-title">Обученные модели</h3>
{#if models.length === 0}
<Text size="sm" color="gray">Моделей пока нет — обучите первую выше.</Text>
{:else}
<div class="model-list">
{#each models as m}
<div class="model-card">
<div class="row spaced">
<strong>{m.name}</strong>
<Badge color="gray" variant="filled" size="sm">{fmtSize(m.size_bytes)}</Badge>
</div>
<div class="model-path">{m.path}</div>
<Text size="xs" color="gray">обновлено: {fmtDate(m.modified)}</Text>
<Space h="xs" />
<Button color="red" radius="md" size="xs" uppercase on:click={() => deleteModel(m.name)}>
<TrashIcon size={12} /> &nbsp; Удалить
</Button>
</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;
}
.section-title {
color: rgba(255, 255, 255, 0.85);
font-size: 1.05rem;
margin: 0 0 0.5rem 0;
}
code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.8rem;
}
.row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.row.spaced {
justify-content: space-between;
}
.session-card, .form-card, .model-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.model-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.model-path {
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
font-family: ui-monospace, "Cascadia Mono", monospace;
word-break: break-all;
margin: 0.2rem 0;
}
</style>