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 }