From 4e21024509eda9e774917a731ed22227996e619a Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 13:26:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Wave=203=20=E2=80=94=20plugin=20system?= =?UTF-8?q?=20+=20custom=20wake-word=20trainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #29 Plugin system: - New jarvis-core::plugins module: discovers user packs in %APPDATA%\com.priler.jarvis\plugins\\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). --- README.md | 25 +- crates/jarvis-core/src/commands.rs | 16 +- crates/jarvis-core/src/db/structs.rs | 16 + crates/jarvis-core/src/i18n/locales/en.ftl | 3 +- crates/jarvis-core/src/i18n/locales/ru.ftl | 3 +- crates/jarvis-core/src/i18n/locales/ua.ftl | 3 +- crates/jarvis-core/src/lib.rs | 4 + crates/jarvis-core/src/listener/rustpotter.rs | 64 ++- crates/jarvis-core/src/plugins.rs | 379 +++++++++++++++ crates/jarvis-core/src/wake_trainer.rs | 439 ++++++++++++++++++ crates/jarvis-gui/src/main.rs | 15 + crates/jarvis-gui/src/tauri_commands.rs | 10 +- .../jarvis-gui/src/tauri_commands/plugins.rs | 47 ++ .../src/tauri_commands/wake_trainer.rs | 50 ++ frontend/src/components/Header.svelte | 4 + frontend/src/routes/plugins/index.svelte | 237 ++++++++++ frontend/src/routes/wake-trainer/index.svelte | 334 +++++++++++++ 17 files changed, 1625 insertions(+), 24 deletions(-) create mode 100644 crates/jarvis-core/src/plugins.rs create mode 100644 crates/jarvis-core/src/wake_trainer.rs create mode 100644 crates/jarvis-gui/src/tauri_commands/plugins.rs create mode 100644 crates/jarvis-gui/src/tauri_commands/wake_trainer.rs create mode 100644 frontend/src/routes/plugins/index.svelte create mode 100644 frontend/src/routes/wake-trainer/index.svelte diff --git a/README.md b/README.md index 594f0bf..e04a638 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,32 @@ LLM через Groq — выбор переключается **голосом** | "Пауза" / "следующий трек" | Windows media keys (Spotify / YouTube / Foobar) | | "Диагностика" / "доложи о себе" | Состояние всех бэкендов одной строкой | -Всего паков: **59** (`resources/commands/*/command.toml`). +Всего паков: **85** (`resources/commands/*/command.toml`) + +плагины пользователя из `%APPDATA%\com.priler.jarvis\plugins\`. Полный список: 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 diff --git a/crates/jarvis-core/src/commands.rs b/crates/jarvis-core/src/commands.rs index be30b28..13bbb10 100644 --- a/crates/jarvis-core/src/commands.rs +++ b/crates/jarvis-core/src/commands.rs @@ -9,7 +9,7 @@ use seqdiff::ratio; mod structs; pub use structs::*; -use crate::{config, i18n, APP_DIR}; +use crate::{config, i18n, plugins, APP_DIR}; #[cfg(feature = "lua")] use crate::lua::{self, SandboxLevel, CommandContext}; @@ -128,10 +128,22 @@ pub fn parse_commands() -> Result, String> { }); } + // Merge plugin packs from /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() { Err("No commands found".into()) } 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) } } diff --git a/crates/jarvis-core/src/db/structs.rs b/crates/jarvis-core/src/db/structs.rs index 8e09e60..cf11c84 100644 --- a/crates/jarvis-core/src/db/structs.rs +++ b/crates/jarvis-core/src/db/structs.rs @@ -46,6 +46,12 @@ pub struct Settings { /// auto-detect. #[serde(default)] 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 `/wake_words/.rpw`. + #[serde(default)] + pub custom_wake_word: 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()), "llm_backend" => Some(self.llm_backend.clone()), "tts_backend" => Some(self.tts_backend.clone()), + "custom_wake_word" => Some(self.custom_wake_word.clone()), _ => None, } } @@ -151,6 +158,13 @@ impl Settings { other => return Err(format!("unknown tts_backend: '{}'", other)), } } + "custom_wake_word" => { + // empty = bundled default; otherwise treated as a file-stem + // under /wake_words/.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)), } Ok(()) @@ -175,6 +189,7 @@ impl Settings { "api_key__openai", "llm_backend", "tts_backend", + "custom_wake_word", ] } } @@ -209,6 +224,7 @@ impl Default for Settings { llm_backend: String::new(), tts_backend: String::new(), + custom_wake_word: String::new(), } } } diff --git a/crates/jarvis-core/src/i18n/locales/en.ftl b/crates/jarvis-core/src/i18n/locales/en.ftl index 7f5a15e..5cdf5a5 100644 --- a/crates/jarvis-core/src/i18n/locales/en.ftl +++ b/crates/jarvis-core/src/i18n/locales/en.ftl @@ -162,4 +162,5 @@ settings-profile = Profile # Header buttons header-macros = Macros header-scheduler = Schedule -header-memory = Memory \ No newline at end of file +header-memory = Memory +header-plugins = Plugins \ No newline at end of file diff --git a/crates/jarvis-core/src/i18n/locales/ru.ftl b/crates/jarvis-core/src/i18n/locales/ru.ftl index a0744d4..faee050 100644 --- a/crates/jarvis-core/src/i18n/locales/ru.ftl +++ b/crates/jarvis-core/src/i18n/locales/ru.ftl @@ -162,4 +162,5 @@ settings-profile = Профиль # Header buttons header-macros = Макросы header-scheduler = Расписание -header-memory = Память \ No newline at end of file +header-memory = Память +header-plugins = Плагины \ No newline at end of file diff --git a/crates/jarvis-core/src/i18n/locales/ua.ftl b/crates/jarvis-core/src/i18n/locales/ua.ftl index 519d3c1..36e1c3f 100644 --- a/crates/jarvis-core/src/i18n/locales/ua.ftl +++ b/crates/jarvis-core/src/i18n/locales/ua.ftl @@ -162,4 +162,5 @@ settings-profile = Профіль # Header buttons header-macros = Макроси header-scheduler = Розклад -header-memory = Пам'ять \ No newline at end of file +header-memory = Пам'ять +header-plugins = Плагіни \ No newline at end of file diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index 7ed72d1..8eca5d7 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -62,6 +62,10 @@ pub mod scheduler; pub mod macros; +pub mod plugins; + +pub mod wake_trainer; + #[cfg(feature = "llm")] pub mod llm; diff --git a/crates/jarvis-core/src/listener/rustpotter.rs b/crates/jarvis-core/src/listener/rustpotter.rs index f0ca5b2..1167333 100644 --- a/crates/jarvis-core/src/listener/rustpotter.rs +++ b/crates/jarvis-core/src/listener/rustpotter.rs @@ -3,7 +3,7 @@ use std::sync::Mutex; use once_cell::sync::OnceCell; use rustpotter::Rustpotter; -use crate::config; +use crate::{config, APP_CONFIG_DIR, DB}; // store rustpotter instance static RUSTPOTTER: OnceCell> = OnceCell::new(); @@ -14,26 +14,56 @@ pub fn init() -> Result<(), ()> { // create rustpotter instance match Rustpotter::new(&rustpotter_config) { Ok(mut rinstance) => { - // success - // wake word files list - // @TODO. Make it configurable via GUI for custom user voice. - let rustpotter_wake_word_files: [&str; 1] = [ - "resources/rustpotter/jarvis-default.rpw", - // "rustpotter/jarvis-community-1.rpw", - // "rustpotter/jarvis-community-2.rpw", - // "rustpotter/jarvis-community-3.rpw", - // "rustpotter/jarvis-community-4.rpw", - // "rustpotter/jarvis-community-5.rpw", - ]; + // Resolve wake-word file list. Priority: + // 1. user-trained custom model (settings.custom_wake_word) + // 2. bundled default + // We always try the bundled default last so the assistant keeps + // working even if the custom model is missing on disk. + let mut loaded_any = false; - // load wake word files - for rpw in rustpotter_wake_word_files { - // @TODO: Change wakeword key to something else? - if let Err(e) = rinstance.add_wakeword_from_file(rpw, rpw) { - error!("Failed to load wakeword file '{}': {}", rpw, e); + if let Some(db) = DB.get() { + let stem = db.read().custom_wake_word.clone(); + if !stem.is_empty() { + if let Some(cfg_dir) = APP_CONFIG_DIR.get() { + let custom = cfg_dir + .join("wake_words") + .join(format!("{}.rpw", stem)); + if custom.is_file() { + let path_str = custom.to_string_lossy().to_string(); + match rinstance.add_wakeword_from_file(&path_str, &path_str) { + Ok(_) => { + info!("Loaded custom wake-word: {}", path_str); + loaded_any = true; + } + Err(e) => warn!( + "Failed to load custom wakeword '{}': {}", + path_str, e + ), + } + } else { + warn!( + "Custom wake-word '{}' selected but file missing: {}", + stem, + custom.display() + ); + } + } } } + const DEFAULT: &str = "resources/rustpotter/jarvis-default.rpw"; + if let Err(e) = rinstance.add_wakeword_from_file(DEFAULT, DEFAULT) { + if !loaded_any { + error!("Failed to load default wakeword '{}': {}", DEFAULT, e); + } + } else { + loaded_any = true; + } + + if !loaded_any { + error!("No wake-word models loaded; Rustpotter will not detect anything."); + } + // store let _ = RUSTPOTTER.set(Mutex::new(rinstance)); } diff --git a/crates/jarvis-core/src/plugins.rs b/crates/jarvis-core/src/plugins.rs new file mode 100644 index 0000000..3566a9b --- /dev/null +++ b/crates/jarvis-core/src/plugins.rs @@ -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 +//! /plugins/ +//! / +//! command.toml (required — same schema as built-in packs) +//! + + + +

Плагины

+ + Дополнительные voice-команды, которые лежат рядом с конфигом + (%APPDATA%\com.priler.jarvis\plugins\). Каждый плагин — это + папка с command.toml и Lua-скриптами. + Меняется только после перезапуска. + + + + +
+ +   + +
+ + + +{#if info} + { info = "" }}> + {info} + + +{/if} + +{#if error} + { error = "" }} + > + {error} + + +{/if} + +{#if loading} + +{:else if plugins.length === 0} + + Плагинов пока нет. Скопируйте папку плагина в указанный каталог + и нажмите «Обновить список». + +{:else} +
+ {#each plugins as p} +
+
+ {p.name} + {#if p.error} + ошибка + {:else} + + {p.command_count} команд + + {/if} +
+
{p.path}
+ {#if p.error} +
{p.error}
+ {/if} +
+ toggle(p)} + /> + + {p.enabled ? "Включён" : "Выключен"} + +
+
+ {/each} +
+{/if} + + + + + + +