feat(macros): IMBA-7 voice-recorded command sequences (VoiceAttack-style)
Simpler than AHK keyboard hooks: record what the user SAID, replay each phrase
through the normal dispatch path. Works for any command jarvis already knows.
Flow:
"Запиши макрос работа" → start_recording("работа")
"Открой браузер" → buffered into the recording
"Запусти спотифай" → buffered
"Режим работа" → buffered
"Сохрани макрос" → save_recording() → persist
...later...
"Запусти макрос работа" → replay("работа") → fires each phrase in order
Core (crates/jarvis-core/src/macros.rs)
- Macro {name, steps, created_at, last_run} persisted at
<APP_CONFIG_DIR>/macros.json with atomic write-through.
- Recording state in-memory: RwLock<Option<RecordingState>>.
- start_recording / record_step / save_recording / cancel_recording.
- list / get / delete / mark_run.
- replay(name) spawns a thread that fires each step through a callback,
with 800ms inter-step delay. Caller returns immediately.
- is_macro_control filter prevents recording the meta-commands themselves
(no infinite recursion when "запиши макрос" runs during a recording).
- 3 unit tests (55 total).
Wiring (crates/jarvis-app/src/main.rs)
- macros::init() before listener starts.
- macros::set_replay_callback(move |phrase| text_cmd_tx.send(phrase)) — each
replay step gets queued as a synthetic text command, processed by the same
dispatcher the GUI uses. No special-case code path.
Recording hook (crates/jarvis-app/src/app.rs::execute_command)
- On successful command execution → call macros::record_step(text).
- Failed commands are NOT recorded (would fail on replay too).
- is_macro_control filter inside record_step skips meta-commands.
Lua API (crates/jarvis-core/src/lua/api/macros.rs)
- jarvis.macros.{start, save, cancel, replay, list, delete,
is_recording, recording_name}
Voice pack (resources/commands/macros/, 6 commands)
- "запиши макрос NAME" → macros.start_recording
- "сохрани макрос" → macros.save
- "отмени макрос" → macros.cancel
- "запусти макрос NAME" → macros.replay
- "какие у меня макросы" → macros.list
- "удали макрос NAME" → macros.delete
Build: cargo build --release -p jarvis-app green. 55/55 tests pass.
This commit is contained in:
parent
d63399f4c9
commit
dbb4a6b888
14 changed files with 594 additions and 2 deletions
|
|
@ -60,6 +60,8 @@ pub mod profiles;
|
|||
|
||||
pub mod scheduler;
|
||||
|
||||
pub mod macros;
|
||||
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod llm;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ pub mod profile;
|
|||
pub mod vision;
|
||||
pub mod scheduler;
|
||||
pub mod cmd;
|
||||
pub mod health;
|
||||
pub mod health;
|
||||
pub mod macros;
|
||||
70
crates/jarvis-core/src/lua/api/macros.rs
Normal file
70
crates/jarvis-core/src/lua/api/macros.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//! Lua bindings for the macro recorder.
|
||||
//!
|
||||
//! Usage from Lua:
|
||||
//! jarvis.macros.start("работа") -- begin recording
|
||||
//! jarvis.macros.save() -- persist + stop
|
||||
//! jarvis.macros.cancel() -- abort without saving
|
||||
//! jarvis.macros.replay("работа") -- play back; returns step count or nil
|
||||
//! jarvis.macros.list() -- array of {name, steps_count, last_run}
|
||||
//! jarvis.macros.delete(name) -- removes
|
||||
//! jarvis.macros.is_recording() -- bool
|
||||
//! jarvis.macros.recording_name() -- string or nil
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use crate::macros;
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
m.set("start", lua.create_function(|_, name: String| {
|
||||
macros::start_recording(&name).map_err(mlua::Error::external)
|
||||
})?)?;
|
||||
|
||||
m.set("save", lua.create_function(|_, ()| {
|
||||
macros::save_recording().map_err(mlua::Error::external)
|
||||
})?)?;
|
||||
|
||||
m.set("cancel", lua.create_function(|_, ()| {
|
||||
Ok(macros::cancel_recording())
|
||||
})?)?;
|
||||
|
||||
m.set("is_recording", lua.create_function(|_, ()| {
|
||||
Ok(macros::is_recording())
|
||||
})?)?;
|
||||
|
||||
m.set("recording_name", lua.create_function(|lua, ()| {
|
||||
match macros::recording_name() {
|
||||
Some(n) => Ok(Value::String(lua.create_string(n)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?)?;
|
||||
|
||||
m.set("replay", lua.create_function(|_, name: String| {
|
||||
macros::replay(&name).map_err(mlua::Error::external)
|
||||
})?)?;
|
||||
|
||||
m.set("delete", lua.create_function(|_, name: String| {
|
||||
Ok(macros::delete(&name))
|
||||
})?)?;
|
||||
|
||||
m.set("list", lua.create_function(|lua, ()| {
|
||||
let arr = lua.create_table()?;
|
||||
for (i, mac) in macros::list().iter().enumerate() {
|
||||
let row = lua.create_table()?;
|
||||
row.set("name", mac.name.clone())?;
|
||||
row.set("steps_count", mac.steps.len())?;
|
||||
row.set("created_at", mac.created_at)?;
|
||||
let last: Value = match mac.last_run {
|
||||
Some(ts) => Value::Integer(ts),
|
||||
None => Value::Nil,
|
||||
};
|
||||
row.set("last_run", last)?;
|
||||
arr.set(i + 1, row)?;
|
||||
}
|
||||
Ok(arr)
|
||||
})?)?;
|
||||
|
||||
jarvis.set("macros", m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ impl LuaEngine {
|
|||
api::scheduler::register(&self.lua, &jarvis)?;
|
||||
api::cmd::register(&self.lua, &jarvis)?;
|
||||
api::health::register(&self.lua, &jarvis)?;
|
||||
api::macros::register(&self.lua, &jarvis)?;
|
||||
|
||||
// sandbox-controlled APIs
|
||||
if self.sandbox.allows_http() {
|
||||
|
|
|
|||
303
crates/jarvis-core/src/macros.rs
Normal file
303
crates/jarvis-core/src/macros.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
//! IMBA-7: Voice-command macros — record a sequence of commands once,
|
||||
//! replay them later with one phrase.
|
||||
//!
|
||||
//! Simpler than AHK-style keyboard hooks: we record what the user SAID, then
|
||||
//! replay each phrase through the normal dispatch path. Works for anything
|
||||
//! that's already a known command (volume, media keys, profile switches, ...).
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//!
|
||||
//! "Запиши макрос работа" → start_recording("работа")
|
||||
//! "Открой браузер" → recorder buffers "открой браузер"
|
||||
//! "Запусти спотифай" → recorder buffers
|
||||
//! "Режим работа" → recorder buffers
|
||||
//! "Сохрани макрос" → save() → persists to disk
|
||||
//!
|
||||
//! "Запусти макрос работа" → replay("работа") → fires each phrase in order,
|
||||
//! with a small inter-step delay
|
||||
//!
|
||||
//! Storage: JSON at `<APP_CONFIG_DIR>/macros.json`.
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::APP_CONFIG_DIR;
|
||||
|
||||
const FILE_NAME: &str = "macros.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Macro {
|
||||
pub name: String,
|
||||
pub steps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: i64,
|
||||
#[serde(default)]
|
||||
pub last_run: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
struct Store {
|
||||
#[serde(default)]
|
||||
macros: BTreeMap<String, Macro>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingState {
|
||||
name: String,
|
||||
steps: Vec<String>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
path: PathBuf,
|
||||
store: RwLock<Store>,
|
||||
recording: RwLock<Option<RecordingState>>,
|
||||
}
|
||||
|
||||
static STATE: OnceCell<State> = OnceCell::new();
|
||||
|
||||
pub fn init() -> Result<(), String> {
|
||||
if STATE.get().is_some() { return Ok(()); }
|
||||
let dir = APP_CONFIG_DIR.get()
|
||||
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
|
||||
let path = dir.join(FILE_NAME);
|
||||
let store = load_or_empty(&path);
|
||||
info!("Macros loaded: {} stored at {}", store.macros.len(), path.display());
|
||||
STATE.set(State { path, store: RwLock::new(store), recording: RwLock::new(None) })
|
||||
.map_err(|_| "macros already initialised".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_or_empty(path: &PathBuf) -> Store {
|
||||
if !path.is_file() { return Store::default(); }
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
|
||||
warn!("Corrupt macros file {}: {}", path.display(), e);
|
||||
Store::default()
|
||||
}),
|
||||
Err(e) => {
|
||||
warn!("Cannot read {}: {}", path.display(), e);
|
||||
Store::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(state: &State) {
|
||||
let store = state.store.read();
|
||||
let tmp = state.path.with_extension("json.tmp");
|
||||
let json = match serde_json::to_string_pretty(&*store) {
|
||||
Ok(s) => s,
|
||||
Err(e) => { warn!("Macros serialize failed: {}", e); return; }
|
||||
};
|
||||
if let Err(e) = std::fs::write(&tmp, json) {
|
||||
warn!("Macros write failed: {}", e); return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, &state.path) {
|
||||
warn!("Macros rename failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn normalize_name(n: &str) -> String {
|
||||
n.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Begin recording a new macro. Replaces any in-progress recording.
|
||||
pub fn start_recording(name: &str) -> Result<(), String> {
|
||||
let state = STATE.get().ok_or_else(|| "macros not init".to_string())?;
|
||||
let nname = normalize_name(name);
|
||||
if nname.is_empty() { return Err("empty macro name".into()); }
|
||||
*state.recording.write() = Some(RecordingState {
|
||||
name: nname.clone(),
|
||||
steps: Vec::new(),
|
||||
});
|
||||
info!("Macros: recording started for '{}'", nname);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Buffer a phrase into the active recording (if any). Called by the command
|
||||
/// dispatch hook after a command runs successfully.
|
||||
pub fn record_step(phrase: &str) {
|
||||
let Some(state) = STATE.get() else { return; };
|
||||
let mut rec = state.recording.write();
|
||||
if let Some(r) = rec.as_mut() {
|
||||
let p = phrase.trim();
|
||||
if p.is_empty() { return; }
|
||||
// Don't record macro-control commands themselves (would be infinite recursion).
|
||||
if is_macro_control(p) { return; }
|
||||
r.steps.push(p.to_string());
|
||||
debug!("Macros: recorded step '{}' for '{}'", p, r.name);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_macro_control(phrase: &str) -> bool {
|
||||
let p = phrase.to_lowercase();
|
||||
p.contains("запиши макрос") || p.contains("сохрани макрос")
|
||||
|| p.contains("отмени макрос") || p.contains("прекрати макрос")
|
||||
|| p.contains("отмени запись")
|
||||
|| p.starts_with("запусти макрос") || p.starts_with("воспроизведи макрос")
|
||||
|| p.starts_with("record macro") || p.starts_with("save macro")
|
||||
|| p.starts_with("play macro")
|
||||
}
|
||||
|
||||
/// Returns true if a recording is currently active.
|
||||
pub fn is_recording() -> bool {
|
||||
STATE.get().and_then(|s| s.recording.read().as_ref().map(|_| ())).is_some()
|
||||
}
|
||||
|
||||
/// Name of the macro currently being recorded, if any.
|
||||
pub fn recording_name() -> Option<String> {
|
||||
STATE.get().and_then(|s| s.recording.read().as_ref().map(|r| r.name.clone()))
|
||||
}
|
||||
|
||||
/// Stop recording and save the macro. Returns the number of steps captured.
|
||||
pub fn save_recording() -> Result<usize, String> {
|
||||
let state = STATE.get().ok_or_else(|| "macros not init".to_string())?;
|
||||
let rec = state.recording.write().take()
|
||||
.ok_or_else(|| "не было активной записи".to_string())?;
|
||||
|
||||
if rec.steps.is_empty() {
|
||||
return Err("макрос пустой".into());
|
||||
}
|
||||
|
||||
let count = rec.steps.len();
|
||||
let m = Macro {
|
||||
name: rec.name.clone(),
|
||||
steps: rec.steps,
|
||||
created_at: now_secs(),
|
||||
last_run: None,
|
||||
};
|
||||
state.store.write().macros.insert(rec.name.clone(), m);
|
||||
persist(state);
|
||||
info!("Macros: saved '{}' ({} steps)", rec.name, count);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Cancel the active recording without saving.
|
||||
pub fn cancel_recording() -> bool {
|
||||
let Some(state) = STATE.get() else { return false; };
|
||||
state.recording.write().take().is_some()
|
||||
}
|
||||
|
||||
pub fn list() -> Vec<Macro> {
|
||||
let Some(state) = STATE.get() else { return Vec::new(); };
|
||||
state.store.read().macros.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn get(name: &str) -> Option<Macro> {
|
||||
let state = STATE.get()?;
|
||||
state.store.read().macros.get(&normalize_name(name)).cloned()
|
||||
}
|
||||
|
||||
pub fn delete(name: &str) -> bool {
|
||||
let Some(state) = STATE.get() else { return false; };
|
||||
let removed = state.store.write().macros.remove(&normalize_name(name)).is_some();
|
||||
if removed { persist(state); }
|
||||
removed
|
||||
}
|
||||
|
||||
/// Mark a macro as run (updates last_run timestamp).
|
||||
pub fn mark_run(name: &str) {
|
||||
let Some(state) = STATE.get() else { return; };
|
||||
let nname = normalize_name(name);
|
||||
{
|
||||
let mut store = state.store.write();
|
||||
if let Some(m) = store.macros.get_mut(&nname) {
|
||||
m.last_run = Some(now_secs());
|
||||
}
|
||||
}
|
||||
persist(state);
|
||||
}
|
||||
|
||||
/// Replay callback — jarvis-app registers a function that fires a text command.
|
||||
/// Macros module owns the timing of replays.
|
||||
pub type ReplayCallback = Box<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
static REPLAY_CB: once_cell::sync::OnceCell<ReplayCallback> = once_cell::sync::OnceCell::new();
|
||||
|
||||
pub fn set_replay_callback(cb: ReplayCallback) {
|
||||
let _ = REPLAY_CB.set(cb);
|
||||
}
|
||||
|
||||
const STEP_DELAY_MS: u64 = 800;
|
||||
|
||||
/// Replay a stored macro. Each step is sent to the registered callback (which
|
||||
/// the app wires to its text-command channel). Inter-step delay gives the
|
||||
/// dispatcher time to process before the next step arrives. Returns step count.
|
||||
pub fn replay(name: &str) -> Result<usize, String> {
|
||||
let m = get(name).ok_or_else(|| format!("Макрос '{}' не найден.", name))?;
|
||||
let cb = REPLAY_CB.get().ok_or_else(|| {
|
||||
"Replay callback not registered (jarvis-app didn't wire it).".to_string()
|
||||
})?;
|
||||
|
||||
let steps_count = m.steps.len();
|
||||
info!("Macros: replay '{}' ({} steps)", m.name, steps_count);
|
||||
let _ = cb; // sanity-checked existence; thread re-reads REPLAY_CB inside.
|
||||
|
||||
// Spawn so the caller (likely a Lua script) returns immediately.
|
||||
let name_owned = m.name.clone();
|
||||
let steps = m.steps.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("macro-replay-{}", name_owned))
|
||||
.spawn(move || {
|
||||
let cb = match REPLAY_CB.get() {
|
||||
Some(c) => c,
|
||||
None => { warn!("Macros: callback vanished mid-replay"); return; }
|
||||
};
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
info!("Macros: replay step {}/{}: {}", i + 1, steps.len(), step);
|
||||
cb(step);
|
||||
if i + 1 < steps.len() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(STEP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
mark_run(&name_owned);
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(steps_count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_macro_control_catches_record_phrase() {
|
||||
assert!(is_macro_control("Запиши макрос работа"));
|
||||
assert!(is_macro_control("сохрани макрос"));
|
||||
assert!(is_macro_control("запусти макрос работа"));
|
||||
assert!(!is_macro_control("открой браузер"));
|
||||
assert!(!is_macro_control("установи громкость 50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_name_lowercases_trims() {
|
||||
assert_eq!(normalize_name(" Работа "), "работа");
|
||||
assert_eq!(normalize_name("GameMode"), "gamemode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macro_serde_round_trip() {
|
||||
let m = Macro {
|
||||
name: "test".into(),
|
||||
steps: vec!["a".into(), "b".into()],
|
||||
created_at: 100,
|
||||
last_run: Some(200),
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
let back: Macro = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.name, "test");
|
||||
assert_eq!(back.steps.len(), 2);
|
||||
assert_eq!(back.last_run, Some(200));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue