new module crates/jarvis-app/src/llm_fallback.rs holds an optional
LlmClient + ConversationHistory built once at startup. if GROQ_TOKEN is
unset the module logs a warning and stays disabled — voice commands keep
working as before.
both the wake-word voice path (recognize_command in app.rs) and the
gui-side text command path (process_text_command) now check whether the
recognized phrase starts with one of the configured trigger phrases
(ru: 'скажи' / 'ответь' / 'произнеси'). when it does, the remainder of
the phrase is sent to Groq and the reply is published as a new
IpcEvent::LlmReply { text } so the gui can speak it.
on api error the trailing user turn is popped from history, the russian
fallback line is sent over ipc and a 'error' voice cue plays. the loop
itself never panics.
177 lines
5 KiB
Rust
177 lines
5 KiB
Rust
use jarvis_core::slots;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::mpsc;
|
|
|
|
// include core
|
|
use jarvis_core::{
|
|
audio, audio_processing, commands, config, db, listener, recorder, stt, intent,
|
|
ipc::{self, IpcAction},
|
|
i18n, voices, models,
|
|
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
|
|
};
|
|
|
|
// include log
|
|
#[macro_use]
|
|
extern crate simple_log;
|
|
mod log;
|
|
|
|
// include app
|
|
mod app;
|
|
mod llm_fallback;
|
|
|
|
// include tray
|
|
// @TODO. macOS currently not supported for tray functionality.
|
|
#[cfg(not(target_os = "macos"))]
|
|
mod tray;
|
|
|
|
static SHOULD_STOP: AtomicBool = AtomicBool::new(false);
|
|
|
|
fn main() -> Result<(), String> {
|
|
// initialize directories
|
|
config::init_dirs()?;
|
|
|
|
// initialize logging
|
|
log::init_logging()?;
|
|
|
|
// log some base info
|
|
info!("Starting Jarvis v{} ...", config::APP_VERSION.unwrap());
|
|
info!("Config directory is: {}", APP_CONFIG_DIR.get().unwrap().display());
|
|
info!("Log directory is: {}", APP_LOG_DIR.get().unwrap().display());
|
|
|
|
// initialize settings
|
|
let settings = db::init();
|
|
|
|
// set global DB (for core modules that read settings at init time)
|
|
DB.set(settings.arc().clone())
|
|
.expect("DB already initialized");
|
|
|
|
// init voices
|
|
let voice_id = settings.lock().voice.clone();
|
|
let language = settings.lock().language.clone();
|
|
if let Err(e) = voices::init(&voice_id, &language) {
|
|
warn!("Failed to init voices: {}", e);
|
|
}
|
|
|
|
// init i18n
|
|
i18n::init(&settings.lock().language);
|
|
|
|
// init LLM fallback (no-op if GROQ_TOKEN missing)
|
|
llm_fallback::init();
|
|
|
|
// init recorder
|
|
if recorder::init().is_err() {
|
|
app::close(1);
|
|
}
|
|
|
|
// init models registry (scans available AI models)
|
|
if let Err(e) = models::init() {
|
|
warn!("Models registry init failed: {}", e);
|
|
}
|
|
|
|
// init stt engine
|
|
if stt::init().is_err() {
|
|
// @TODO. Allow continuing even without STT, if commands is using keywords or smthng?
|
|
app::close(1); // cannot continue without stt
|
|
}
|
|
|
|
// init commands
|
|
info!("Initializing commands.");
|
|
let cmds = match commands::parse_commands() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
warn!("Failed to parse commands: {}. Starting with empty command list.", e);
|
|
Vec::new()
|
|
}
|
|
};
|
|
info!("Commands initialized. Count: {}, List: {:?}", cmds.len(), commands::list_paths(&cmds));
|
|
COMMANDS_LIST.set(cmds).unwrap();
|
|
|
|
// init audio
|
|
if audio::init().is_err() {
|
|
// @TODO. Allow continuing even without audio?
|
|
app::close(1); // cannot continue without audio
|
|
}
|
|
|
|
// init wake-word engine
|
|
if let Err(e) = listener::init() {
|
|
error!("Wake-word engine init failed: {}", e);
|
|
app::close(1);
|
|
}
|
|
|
|
// shared async runtime for intent classification, IPC, etc.
|
|
let rt = Arc::new(
|
|
tokio::runtime::Runtime::new().expect("Failed to create tokio runtime")
|
|
);
|
|
|
|
// init intent-recognition engine
|
|
rt.block_on(async {
|
|
if let Err(e) = intent::init(COMMANDS_LIST.get().unwrap()).await {
|
|
error!("Failed to initialize intent classifier: {}", e);
|
|
app::close(1);
|
|
}
|
|
});
|
|
|
|
// init slots parsing engine
|
|
slots::init().map_err(|e| error!("Slot extraction init failed: {}", e)).ok();
|
|
|
|
// init audio processing
|
|
info!("Initializing audio processing...");
|
|
if let Err(e) = audio_processing::init() {
|
|
warn!("Audio processing init failed: {}", e);
|
|
}
|
|
|
|
// init IPC
|
|
info!("Initializing IPC...");
|
|
ipc::init();
|
|
|
|
// channel for text commands (manually written in the GUI)
|
|
let (text_cmd_tx, text_cmd_rx) = mpsc::channel::<String>();
|
|
|
|
ipc::set_action_handler(move |action| {
|
|
match action {
|
|
IpcAction::Stop => {
|
|
info!("Received stop command from GUI");
|
|
SHOULD_STOP.store(true, Ordering::SeqCst);
|
|
}
|
|
IpcAction::ReloadCommands => {
|
|
info!("Received reload commands request");
|
|
// TODO: implement reload
|
|
}
|
|
IpcAction::SetMuted { muted } => {
|
|
info!("Received mute request: {}", muted);
|
|
// TODO: implement mute
|
|
}
|
|
IpcAction::TextCommand { text } => {
|
|
info!("Received text command: {}", text);
|
|
if let Err(e) = text_cmd_tx.send(text) {
|
|
error!("Failed to send text command to app: {}", e);
|
|
}
|
|
}
|
|
IpcAction::Ping => {
|
|
// handled internally by server
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
|
|
// start WebSocket server on the shared runtime
|
|
let ipc_rt = Arc::clone(&rt);
|
|
std::thread::spawn(move || {
|
|
ipc_rt.block_on(ipc::start_server());
|
|
});
|
|
|
|
// start the app (in the background thread)
|
|
let app_rt = Arc::clone(&rt);
|
|
std::thread::spawn(move || {
|
|
let _ = app::start(text_cmd_rx, &app_rt);
|
|
});
|
|
|
|
tray::init_blocking(settings);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn should_stop() -> bool {
|
|
SHOULD_STOP.load(Ordering::SeqCst)
|
|
}
|