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:
parent
c4b22618f8
commit
4e21024509
17 changed files with 1625 additions and 24 deletions
25
README.md
25
README.md
|
|
@ -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 — мастер: запишите 5–30 примеров своего голоса,
|
||||||
|
обучите персональную `.rpw`-модель, выберите её в Настройках, перезапустите
|
||||||
|
ассистент. Файл — `%APPDATA%\com.priler.jarvis\wake_words\<имя>.rpw`.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -163,3 +163,4 @@ settings-profile = Профиль
|
||||||
header-macros = Макросы
|
header-macros = Макросы
|
||||||
header-scheduler = Расписание
|
header-scheduler = Расписание
|
||||||
header-memory = Память
|
header-memory = Память
|
||||||
|
header-plugins = Плагины
|
||||||
|
|
@ -163,3 +163,4 @@ settings-profile = Профіль
|
||||||
header-macros = Макроси
|
header-macros = Макроси
|
||||||
header-scheduler = Розклад
|
header-scheduler = Розклад
|
||||||
header-memory = Пам'ять
|
header-memory = Пам'ять
|
||||||
|
header-plugins = Плагіни
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
379
crates/jarvis-core/src/plugins.rs
Normal file
379
crates/jarvis-core/src/plugins.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
439
crates/jarvis-core/src/wake_trainer.rs
Normal file
439
crates/jarvis-core/src/wake_trainer.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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::*;
|
||||||
47
crates/jarvis-gui/src/tauri_commands/plugins.rs
Normal file
47
crates/jarvis-gui/src/tauri_commands/plugins.rs
Normal 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())
|
||||||
|
}
|
||||||
50
crates/jarvis-gui/src/tauri_commands/wake_trainer.rs
Normal file
50
crates/jarvis-gui/src/tauri_commands/wake_trainer.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
237
frontend/src/routes/plugins/index.svelte
Normal file
237
frontend/src/routes/plugins/index.svelte
Normal 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} /> Открыть папку
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="gray" radius="md" size="sm" on:click={load}>
|
||||||
|
<ReloadIcon size={14} /> Обновить список
|
||||||
|
</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>
|
||||||
334
frontend/src/routes/wake-trainer/index.svelte
Normal file
334
frontend/src/routes/wake-trainer/index.svelte
Normal 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">
|
||||||
|
Запишите 5–30 примеров своего голоса, и Jarvis обучит персональный
|
||||||
|
Rustpotter-детектор. Файл сохраняется как
|
||||||
|
<code>%APPDATA%\com.priler.jarvis\wake_words\<имя>.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} />
|
||||||
|
{busy ? "Запись..." : `Записать сэмпл (${status.collected + 1})`}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<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} /> Обучить и сохранить
|
||||||
|
</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 минимум, 10–15 рекомендовано"
|
||||||
|
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} /> Начать запись
|
||||||
|
</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} /> Удалить
|
||||||
|
</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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue