| 1 | //! Tauri command: `chat_send_message` |
| 2 | //! |
| 3 | //! Sends a user message to the on-device LLM and returns `Ok(())` |
| 4 | //! immediately. The actual reply (or error) is delivered to the frontend |
| 5 | //! via the `chat_reply` Tauri event emitted from a spawned async task. |
| 6 | |
| 7 | use super::*; |
| 8 | use tauri::AppHandle; |
| 9 | |
| 10 | #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))] |
| 11 | use {log::info, tauri::Emitter}; |
| 12 | |
| 13 | #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "android")))] |
| 14 | use log::debug; |
| 15 | |
| 16 | /// Send a user message to Siti and receive an assistant reply. |
| 17 | /// |
| 18 | /// **Fire-and-return-immediately design**: this command returns `Ok(())` |
| 19 | /// as soon as the inference task is spawned. The actual reply (or error) |
| 20 | /// is delivered to the frontend via the `chat_reply` Tauri event |
| 21 | /// (`EVENT_CHAT_REPLY`). |
| 22 | /// |
| 23 | /// ## Why events instead of a blocking return? |
| 24 | /// |
| 25 | /// On Android, the Tauri IPC bridge stores the JS-side `invoke()` resolve |
| 26 | /// callback in a V8 `Map`. Android's WebView GC can collect that entry |
| 27 | /// while the command is still running, especially on budget SoCs where |
| 28 | /// GGUF inference takes 60-300 seconds. Emitting an event from a spawned |
| 29 | /// task sidesteps the GC entirely. |
| 30 | #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))] |
| 31 | #[tauri::command] |
| 32 | pub async fn chat_send_message(app: AppHandle, message: String) -> Result<(), String> { |
| 33 | // ── 1. Lazy-load the model if not already cached ────────────────────── |
| 34 | if !ENGINE.is_loaded().await { |
| 35 | info!("No chat model loaded; lazy-loading default model."); |
| 36 | |
| 37 | let config = model_config(); |
| 38 | let display_name = config.display_name.clone(); |
| 39 | |
| 40 | emit_chat_status(&app, ChatStatus::Loading, Some(&display_name), None); |
| 41 | |
| 42 | ENGINE |
| 43 | .load_gguf_model( |
| 44 | config, |
| 45 | Some(CHAT_SYSTEM_PROMPT.to_string()), |
| 46 | Some(sampling_config()), |
| 47 | ) |
| 48 | .await |
| 49 | .map_err(|e| { |
| 50 | let msg = format!("Failed to lazy-load chat model: {}", e); |
| 51 | error!("{}", msg); |
| 52 | emit_chat_status(&app, ChatStatus::Error, Some(&display_name), Some(&msg)); |
| 53 | msg |
| 54 | })?; |
| 55 | |
| 56 | emit_chat_status(&app, ChatStatus::Ready, Some(&display_name), None); |
| 57 | } |
| 58 | |
| 59 | // ── 2. Spawn inference task: this command returns Ok(()) immediately ── |
| 60 | let history_len = ENGINE.history().await.len(); |
| 61 | info!( |
| 62 | "Chat inference DISPATCH history_turns={} message=\"{}\"", |
| 63 | history_len, message |
| 64 | ); |
| 65 | |
| 66 | tokio::task::spawn(async move { |
| 67 | info!("Chat inference START"); |
| 68 | |
| 69 | let result = ENGINE.send_message(&message).await; |
| 70 | |
| 71 | let payload = match result { |
| 72 | Ok(inference_result) => { |
| 73 | info!( |
| 74 | "Chat inference END ({}) reply: \"{}\"", |
| 75 | inference_result.duration_display, |
| 76 | if inference_result.text.len() > 100 { |
| 77 | format!("{}...", &inference_result.text[..100]) |
| 78 | } else { |
| 79 | inference_result.text.clone() |
| 80 | } |
| 81 | ); |
| 82 | |
| 83 | ChatReplyPayload { |
| 84 | reply: Some(inference_result.text), |
| 85 | duration: Some(inference_result.duration_display), |
| 86 | error: None, |
| 87 | } |
| 88 | } |
| 89 | Err(e) => { |
| 90 | let msg = format!("Chat inference FAILED: {}", e); |
| 91 | error!("{}", msg); |
| 92 | emit_chat_status(&app, ChatStatus::Error, None, Some(&msg)); |
| 93 | ChatReplyPayload { |
| 94 | reply: None, |
| 95 | duration: None, |
| 96 | error: Some(msg), |
| 97 | } |
| 98 | } |
| 99 | }; |
| 100 | |
| 101 | if let Err(e) = app.emit(EVENT_CHAT_REPLY, &payload) { |
| 102 | error!("Failed to emit chat_reply event: {:?}", e); |
| 103 | } |
| 104 | }); |
| 105 | |
| 106 | Ok(()) |
| 107 | } |
| 108 | |
| 109 | #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "android")))] |
| 110 | #[tauri::command] |
| 111 | pub async fn chat_send_message(_app: AppHandle, _message: String) -> Result<(), String> { |
| 112 | debug!("Siti chat is only supported on macOS, iOS, and Android for now."); |
| 113 | Err("Siti chat is only supported on macOS, iOS, and Android for now.".to_string()) |
| 114 | } |