Raw
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 #[cfg(any(
8 target_os = "macos",
9 target_os = "ios",
10 target_os = "android",
11 target_os = "windows"
12 ))]
13 use {
14 super::{resolved_model_config, sampling_config, CHAT_SYSTEM_PROMPT},
15 ed_agent_tauri::EdState,
16 log::{error, info},
17 std::sync::atomic::{AtomicU64, Ordering},
18 tauri::{AppHandle, State},
19 };
20
21 #[cfg(not(any(
22 target_os = "macos",
23 target_os = "ios",
24 target_os = "android",
25 target_os = "windows"
26 )))]
27 use log::debug;
28
29 /// Distinguishes turns in `chat_text_delta` and `chat_reply` payloads.
30 #[cfg(any(
31 target_os = "macos",
32 target_os = "ios",
33 target_os = "android",
34 target_os = "windows"
35 ))]
36 static NEXT_TURN: AtomicU64 = AtomicU64::new(1);
37
38 /// Send a user message to Siti and receive an assistant reply.
39 ///
40 /// **Fire-and-return-immediately design**: this command returns `Ok(())`
41 /// as soon as the inference task is spawned. The actual reply (or error)
42 /// is delivered to the frontend via the `chat_reply` Tauri event
43 /// (`EVENT_CHAT_REPLY`).
44 ///
45 /// ## Why events instead of a blocking return?
46 ///
47 /// On Android, the Tauri IPC bridge stores the JS-side `invoke()` resolve
48 /// callback in a V8 `Map`. Android's WebView GC can collect that entry
49 /// while the command is still running, especially on budget SoCs where
50 /// GGUF inference takes 60-300 seconds. Emitting an event from a spawned
51 /// task sidesteps the GC entirely.
52 ///
53 /// While the model generates, the reply also arrives piece by piece as
54 /// `chat_text_delta` events; `chat_reply` then carries the whole text.
55 #[cfg(any(
56 target_os = "macos",
57 target_os = "ios",
58 target_os = "android",
59 target_os = "windows"
60 ))]
61 #[tauri::command]
62 pub async fn chat_send_message(
63 app: AppHandle,
64 state: State<'_, EdState>,
65 message: String,
66 ) -> Result<(), String> {
67 // ── 1. Lazy-load the model if not already cached ──────────────────────
68 // Ed reports `Loading`, then `Ready` or `Error`.
69 if !state.ed().is_loaded().await {
70 info!("No chat model loaded; lazy-loading default model.");
71
72 resolved_model_config()
73 .load(
74 state.ed(),
75 Some(CHAT_SYSTEM_PROMPT.to_string()),
76 Some(sampling_config()),
77 )
78 .await
79 .map_err(|e| {
80 let msg = format!("Failed to lazy-load chat model: {}", e);
81 error!("{}", msg);
82 msg
83 })?;
84 }
85
86 // ── 2. Run the turn on a spawned task; this command returns at once ───
87 // `submit` emits `chat_text_delta` while generating and `chat_reply` at
88 // the end, with `{id, session, reply, duration, error}`.
89 info!(
90 "Chat inference DISPATCH history_turns={} message_chars={}",
91 state.ed().history().await.len(),
92 message.chars().count()
93 );
94 let id = NEXT_TURN.fetch_add(1, Ordering::Relaxed).to_string();
95 state.submit(app, id, message);
96
97 Ok(())
98 }
99
100 #[cfg(not(any(
101 target_os = "macos",
102 target_os = "ios",
103 target_os = "android",
104 target_os = "windows"
105 )))]
106 #[tauri::command]
107 pub async fn chat_send_message(_message: String) -> Result<(), String> {
108 debug!("Siti chat is not supported on this platform.");
109 Err("Siti chat is not supported on this platform.".to_string())
110 }