@sigit / siti / commits / 615cb97

Move the Tauri chat onto Ed

The engine now lives in Ed, managed as EdState. Model status, token deltas and replies reach the webview through ed-agent-tauri, so the status, history and clear commands come from the SDK and the desktop UI streams replies into the assistant bubble. The Cargo patch points at a sibling onde-ed until Ed 1.1.0 is published. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

paydii committed Oct 8, 2026 at 22:27 UTC 615cb97a43749a94d36704c494cde4be67db151a
15 files changed +233 -393
src-tauri/Cargo.lock
+32 -2
@@ -1831,6 +1831,34 @@ version = "0.1.3"
1831 source = "registry+https://github.com/rust-lang/crates.io-index"
1832 checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
1833
1834 +[[package]]
1835 +name = "ed-agent"
1836 +version = "1.1.0"
1837 +dependencies = [
1838 + "async-trait",
1839 + "futures-core",
1840 + "log",
1841 + "onde",
1842 + "serde",
1843 + "serde_json",
1844 + "thiserror 2.0.20",
1845 + "tokio",
1846 +]
1847 +
1848 +[[package]]
1849 +name = "ed-agent-tauri"
1850 +version = "1.1.0"
1851 +dependencies = [
1852 + "async-trait",
1853 + "ed-agent",
1854 + "log",
1855 + "serde",
1856 + "serde_json",
1857 + "tauri",
1858 + "tokio",
1859 + "uuid 1.24.0",
1860 +]
1861 +
1862 [[package]]
1863 name = "ego-tree"
1864 version = "0.10.0"
@@ -4666,9 +4694,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
4694
4695 [[package]]
4696 name = "onde"
4669 -version = "1.2.2"
4697 +version = "1.3.1"
4698 source = "registry+https://github.com/rust-lang/crates.io-index"
4671 -checksum = "8d84f62e53e6ce8efce62baac8e769d08f83ff19ed6cd9d1a91eeb769fedd618"
4699 +checksum = "7145ab132138de1f7b57707d37a5f7dbcb0dee36560742689270c162359478b5"
4700 dependencies = [
4701 "anyhow",
4702 "cc",
@@ -6821,6 +6849,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
6849 name = "sitiai"
6850 version = "1.0.0"
6851 dependencies = [
6852 + "ed-agent",
6853 + "ed-agent-tauri",
6854 "log",
6855 "objc2-foundation",
6856 "once_cell",
src-tauri/Cargo.toml
+14 -10
@@ -29,11 +29,14 @@ serde_json = "1"
29 log = "0.4"
30 once_cell = "1"
31 tokio = { version = "1", features = ["macros", "rt"] }
32 -# On-device LLM inference engine (Metal on Apple, CPU on Android).
33 -# Published crate: https://crates.io/crates/onde — this is the SDK Siti showcases.
34 -# For local SDK development, uncomment the [patch.crates-io] block at the bottom
35 -# of this file to build against a sibling `onde` checkout instead.
36 -onde = "1.2.2"
32 +# Ed, the agent SDK over the Onde inference engine: model lifecycle, history,
33 +# streaming and the Tauri event bridge. Published crates:
34 +# https://crates.io/crates/ed-agent and https://crates.io/crates/ed-agent-tauri.
35 +ed-agent = "1.1.0"
36 +ed-agent-tauri = "1.1.0"
37 +# The engine Ed wraps. Siti still reaches it directly for the Hugging Face cache
38 +# helpers (download checks, model removal) and the macOS ISQ load path.
39 +onde = "1.3.1"
40
41 # Apple App Group container lookup — used to redirect the HuggingFace model
42 # cache into the shared `group.com.ondeinference.apps` container.
@@ -62,8 +65,9 @@ strip = "debuginfo"
65 [profile.release.build-override]
66 strip = false
67
65 -# ── Local Onde SDK development ─────────────────────────────────────────────────
66 -# Uncomment to build Siti against a local checkout of the Onde engine (sibling
67 -# directory) instead of the published crate. Keep this commented on `main`.
68 -# [patch.crates-io]
69 -# onde = { path = "../../onde" }
68 +# ── Local SDK development ─────────────────────────────────────────────────────
69 +# Builds Siti against a sibling checkout of onde-ed until Ed 1.1.0 is on
70 +# crates.io. Delete this block after the release; nothing else changes.
71 +[patch.crates-io]
72 +ed-agent = { path = "../../onde-ed/crates/ed-agent" }
73 +ed-agent-tauri = { path = "../../onde-ed/crates/ed-agent-tauri" }
src-tauri/src/chat/command_clear_history.rs deleted
-45
@@ -1,45 +0,0 @@
1 -//! Tauri command: `chat_clear_history`
2 -//!
3 -//! Clears the conversation history but keeps the model loaded in memory.
4 -
5 -use log::debug;
6 -
7 -#[cfg(any(
8 - target_os = "macos",
9 - target_os = "ios",
10 - target_os = "android",
11 - target_os = "windows"
12 -))]
13 -use {super::ENGINE, log::info};
14 -
15 -/// Clear the conversation history but keep the model loaded.
16 -#[cfg(any(
17 - target_os = "macos",
18 - target_os = "ios",
19 - target_os = "android",
20 - target_os = "windows"
21 -))]
22 -#[tauri::command]
23 -pub async fn chat_clear_history() -> Result<String, String> {
24 - if ENGINE.is_loaded().await {
25 - let count = ENGINE.history().await.len();
26 - ENGINE.clear_history().await;
27 - info!("Chat history cleared ({} turns removed).", count);
28 - Ok(format!("Chat history cleared ({} turns removed).", count))
29 - } else {
30 - debug!("No chat model loaded; nothing to clear.");
31 - Ok("No chat model loaded.".to_string())
32 - }
33 -}
34 -
35 -#[cfg(not(any(
36 - target_os = "macos",
37 - target_os = "ios",
38 - target_os = "android",
39 - target_os = "windows"
40 -)))]
41 -#[tauri::command]
42 -pub async fn chat_clear_history() -> Result<String, String> {
43 - debug!("Siti chat is not supported on this platform.");
44 - Ok("No chat model loaded.".to_string())
45 -}
src-tauri/src/chat/command_get_history.rs deleted
-48
@@ -1,48 +0,0 @@
1 -//! Tauri command: `chat_get_history`
2 -//!
3 -//! Returns the full conversation history from the cached chat state.
4 -
5 -use super::ChatMessagePayload;
6 -
7 -#[cfg(any(
8 - target_os = "macos",
9 - target_os = "ios",
10 - target_os = "android",
11 - target_os = "windows"
12 -))]
13 -use {super::ENGINE, onde::inference::ChatRole};
14 -
15 -/// Return the full conversation history.
16 -#[cfg(any(
17 - target_os = "macos",
18 - target_os = "ios",
19 - target_os = "android",
20 - target_os = "windows"
21 -))]
22 -#[tauri::command]
23 -pub async fn chat_get_history() -> Vec<ChatMessagePayload> {
24 - ENGINE
25 - .history()
26 - .await
27 - .into_iter()
28 - .map(|msg| ChatMessagePayload {
29 - role: match msg.role {
30 - ChatRole::User => "user".to_string(),
31 - ChatRole::Assistant => "assistant".to_string(),
32 - ChatRole::System => "system".to_string(),
33 - },
34 - content: msg.content,
35 - })
36 - .collect()
37 -}
38 -
39 -#[cfg(not(any(
40 - target_os = "macos",
41 - target_os = "ios",
42 - target_os = "android",
43 - target_os = "windows"
44 -)))]
45 -#[tauri::command]
46 -pub async fn chat_get_history() -> Vec<ChatMessagePayload> {
47 - Vec::new()
48 -}
src-tauri/src/chat/command_get_status.rs deleted
-56
@@ -1,56 +0,0 @@
1 -//! Tauri command: `chat_get_status`
2 -//!
3 -//! Returns the current status of the chat model and conversation.
4 -
5 -use super::ChatStatusResponse;
6 -
7 -#[cfg(any(
8 - target_os = "macos",
9 - target_os = "ios",
10 - target_os = "android",
11 - target_os = "windows"
12 -))]
13 -use {super::ENGINE, crate::constants::ChatStatus, onde::inference::EngineStatus};
14 -
15 -/// Get the current status of the chat model and conversation.
16 -#[cfg(any(
17 - target_os = "macos",
18 - target_os = "ios",
19 - target_os = "android",
20 - target_os = "windows"
21 -))]
22 -#[tauri::command]
23 -pub async fn chat_get_status() -> ChatStatusResponse {
24 - let info = ENGINE.info().await;
25 - let status = match info.status {
26 - EngineStatus::Ready => ChatStatus::Ready,
27 - EngineStatus::Unloaded => ChatStatus::Unloaded,
28 - EngineStatus::Loading => ChatStatus::Loading,
29 - EngineStatus::Generating => ChatStatus::Ready,
30 - EngineStatus::Error => ChatStatus::Error,
31 - };
32 - ChatStatusResponse {
33 - status,
34 - model_name: info.model_name,
35 - approx_memory: info.approx_memory,
36 - history_length: info.history_length as usize,
37 - }
38 -}
39 -
40 -#[cfg(not(any(
41 - target_os = "macos",
42 - target_os = "ios",
43 - target_os = "android",
44 - target_os = "windows"
45 -)))]
46 -#[tauri::command]
47 -pub async fn chat_get_status() -> ChatStatusResponse {
48 - use crate::constants::ChatStatus;
49 -
50 - ChatStatusResponse {
51 - status: ChatStatus::Unloaded,
52 - model_name: None,
53 - approx_memory: None,
54 - history_length: 0,
55 - }
56 -}
src-tauri/src/chat/command_load_model.rs
+8 -12
@@ -5,8 +5,6 @@
5 //! only network access is the one-time HuggingFace download of the model
6 //! weights into the shared App Group cache.
7
8 -use tauri::AppHandle;
9 -
8 #[cfg(any(
9 target_os = "macos",
10 target_os = "ios",
@@ -14,14 +12,14 @@ use tauri::AppHandle;
12 target_os = "windows"
13 ))]
14 use {
17 - super::{
18 - emit_chat_status, fmt_duration, resolved_model_config, sampling_config, CHAT_SYSTEM_PROMPT,
19 - },
20 - crate::constants::ChatStatus,
15 + super::{fmt_duration, resolved_model_config, sampling_config, CHAT_SYSTEM_PROMPT},
16 + ed_agent_tauri::EdState,
17 log::{error, info},
18 + tauri::State,
19 };
20
24 -/// Load the platform-default chat model into memory.
21 +/// Load the platform-default chat model into memory. Ed reports the
22 +/// `Loading` -> `Ready`/`Error` transitions as `chat_status_changed`.
23 #[cfg(any(
24 target_os = "macos",
25 target_os = "ios",
@@ -29,14 +27,14 @@ use {
27 target_os = "windows"
28 ))]
29 #[tauri::command]
32 -pub async fn chat_load_model(app: AppHandle) -> Result<String, String> {
30 +pub async fn chat_load_model(state: State<'_, EdState>) -> Result<String, String> {
31 let config = resolved_model_config();
32 let display_name = config.display_name();
33 info!("chat_load_model: loading on-device model {}", display_name);
36 - emit_chat_status(&app, ChatStatus::Loading, Some(&display_name), None);
34
35 let elapsed = config
36 .load(
37 + state.ed(),
38 Some(CHAT_SYSTEM_PROMPT.to_string()),
39 Some(sampling_config()),
40 )
@@ -44,7 +42,6 @@ pub async fn chat_load_model(app: AppHandle) -> Result<String, String> {
42 .map_err(|e| {
43 let msg = format!("Failed to load chat model: {}", e);
44 error!("{}", msg);
47 - emit_chat_status(&app, ChatStatus::Error, Some(&display_name), Some(&msg));
45 msg
46 })?;
47
@@ -54,7 +51,6 @@ pub async fn chat_load_model(app: AppHandle) -> Result<String, String> {
51 fmt_duration(elapsed)
52 );
53 info!("{}", msg);
57 - emit_chat_status(&app, ChatStatus::Ready, Some(&display_name), None);
54 Ok(msg)
55 }
56
@@ -65,7 +61,7 @@ pub async fn chat_load_model(app: AppHandle) -> Result<String, String> {
61 target_os = "windows"
62 )))]
63 #[tauri::command]
68 -pub async fn chat_load_model(_app: AppHandle) -> Result<String, String> {
64 +pub async fn chat_load_model() -> Result<String, String> {
65 log::debug!("Chat model is not supported on this platform.");
66 Err("Chat model is not supported on this platform.".to_string())
67 }
src-tauri/src/chat/command_remove_model.rs
+11 -9
@@ -4,8 +4,6 @@
4 //! space. If the model being removed is the one currently loaded in memory, it
5 //! is unloaded first so the engine doesn't keep a now-orphaned model resident.
6
7 -use tauri::AppHandle;
8 -
7 #[cfg(any(
8 target_os = "macos",
9 target_os = "ios",
@@ -13,9 +11,10 @@ use tauri::AppHandle;
11 target_os = "windows"
12 ))]
13 use {
16 - super::{emit_chat_status, resolved_model_config, ENGINE, SELECTED_MODEL},
17 - crate::constants::ChatStatus,
14 + super::{resolved_model_config, SELECTED_MODEL},
15 + ed_agent_tauri::EdState,
16 log::{error, info},
17 + tauri::State,
18 };
19
20 /// Delete the locally cached weights for `model_id` (a HuggingFace repo id from
@@ -27,7 +26,10 @@ use {
26 target_os = "windows"
27 ))]
28 #[tauri::command]
30 -pub async fn chat_remove_model(app: AppHandle, model_id: String) -> Result<String, String> {
29 +pub async fn chat_remove_model(
30 + state: State<'_, EdState>,
31 + model_id: String,
32 +) -> Result<String, String> {
33 // If we're deleting the model that's currently loaded/selected, unload it
34 // first so the engine isn't left holding weights we're about to remove.
35 let is_selected = SELECTED_MODEL
@@ -35,11 +37,11 @@ pub async fn chat_remove_model(app: AppHandle, model_id: String) -> Result<Strin
37 .map(|g| *g == model_id)
38 .unwrap_or(false);
39
38 - if is_selected && ENGINE.is_loaded().await {
40 + if is_selected && state.ed().is_loaded().await {
41 let display_name = resolved_model_config().display_name();
40 - ENGINE.unload_model().await;
42 + // Ed reports the `Unloaded` transition.
43 + state.ed().unload().await;
44 info!("Unloaded {display_name} before removing its weights.");
42 - emit_chat_status(&app, ChatStatus::Unloaded, None, None);
45 }
46
47 onde::hf_cache::delete_local_hf_model(model_id.clone()).map_err(|e| {
@@ -60,7 +62,7 @@ pub async fn chat_remove_model(app: AppHandle, model_id: String) -> Result<Strin
62 target_os = "windows"
63 )))]
64 #[tauri::command]
63 -pub async fn chat_remove_model(_app: AppHandle, _model_id: String) -> Result<String, String> {
65 +pub async fn chat_remove_model(_model_id: String) -> Result<String, String> {
66 log::debug!("Model management is not supported on this platform.");
67 Err("Model management is not supported on this platform.".to_string())
68 }
src-tauri/src/chat/command_send_message.rs
+37 -60
@@ -4,16 +4,19 @@
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 -
7 #[cfg(any(
8 target_os = "macos",
9 target_os = "ios",
10 target_os = "android",
11 target_os = "windows"
12 ))]
16 -use {log::info, tauri::Emitter};
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,6 +26,15 @@ use {log::info, tauri::Emitter};
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(())`
@@ -37,6 +49,9 @@ use log::debug;
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",
@@ -44,18 +59,19 @@ use log::debug;
59 target_os = "windows"
60 ))]
61 #[tauri::command]
47 -pub async fn chat_send_message(app: AppHandle, message: String) -> Result<(), String> {
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 ──────────────────────
49 - if !ENGINE.is_loaded().await {
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
52 - let config = resolved_model_config();
53 - let display_name = config.display_name();
54 -
55 - emit_chat_status(&app, ChatStatus::Loading, Some(&display_name), None);
56 -
57 - config
72 + resolved_model_config()
73 .load(
74 + state.ed(),
75 Some(CHAT_SYSTEM_PROMPT.to_string()),
76 Some(sampling_config()),
77 )
@@ -63,59 +79,20 @@ pub async fn chat_send_message(app: AppHandle, message: String) -> Result<(), St
79 .map_err(|e| {
80 let msg = format!("Failed to lazy-load chat model: {}", e);
81 error!("{}", msg);
66 - emit_chat_status(&app, ChatStatus::Error, Some(&display_name), Some(&msg));
82 msg
83 })?;
69 -
70 - emit_chat_status(&app, ChatStatus::Ready, Some(&display_name), None);
84 }
85
73 - // ── 2. Spawn inference task: this command returns Ok(()) immediately ──
74 - let history_len = ENGINE.history().await.len();
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!(
76 - "Chat inference DISPATCH history_turns={} message=\"{}\"",
77 - history_len, message
90 + "Chat inference DISPATCH history_turns={} message_chars={}",
91 + state.ed().history().await.len(),
92 + message.chars().count()
93 );
79 -
80 - tokio::task::spawn(async move {
81 - info!("Chat inference START");
82 -
83 - let result = ENGINE.send_message(&message).await;
84 -
85 - let payload = match result {
86 - Ok(inference_result) => {
87 - info!(
88 - "Chat inference END ({}) reply: \"{}\"",
89 - inference_result.duration_display,
90 - if inference_result.text.len() > 100 {
91 - format!("{}...", &inference_result.text[..100])
92 - } else {
93 - inference_result.text.clone()
94 - }
95 - );
96 -
97 - ChatReplyPayload {
98 - reply: Some(inference_result.text),
99 - duration: Some(inference_result.duration_display),
100 - error: None,
101 - }
102 - }
103 - Err(e) => {
104 - let msg = format!("Chat inference FAILED: {}", e);
105 - error!("{}", msg);
106 - emit_chat_status(&app, ChatStatus::Error, None, Some(&msg));
107 - ChatReplyPayload {
108 - reply: None,
109 - duration: None,
110 - error: Some(msg),
111 - }
112 - }
113 - };
114 -
115 - if let Err(e) = app.emit(EVENT_CHAT_REPLY, &payload) {
116 - error!("Failed to emit chat_reply event: {:?}", e);
117 - }
118 - });
94 + let id = NEXT_TURN.fetch_add(1, Ordering::Relaxed).to_string();
95 + state.submit(app, id, message);
96
97 Ok(())
98 }
@@ -127,7 +104,7 @@ pub async fn chat_send_message(app: AppHandle, message: String) -> Result<(), St
104 target_os = "windows"
105 )))]
106 #[tauri::command]
130 -pub async fn chat_send_message(_app: AppHandle, _message: String) -> Result<(), String> {
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 }
src-tauri/src/chat/command_set_model.rs
+19 -24
@@ -7,8 +7,6 @@
7 //! `chat_status_changed` event (Loading → Ready, or Error) rather than a
8 //! blocking return that the WebView could GC out from under us.
9
10 -use tauri::AppHandle;
11 -
10 #[cfg(any(
11 target_os = "macos",
12 target_os = "ios",
@@ -17,11 +15,12 @@ use tauri::AppHandle;
15 ))]
16 use {
17 super::{
20 - emit_chat_status, fmt_duration, resolve_model_id, resolved_model_config, sampling_config,
21 - CHAT_SYSTEM_PROMPT, ENGINE, SELECTED_MODEL,
18 + fmt_duration, resolve_model_id, resolved_model_config, sampling_config, CHAT_SYSTEM_PROMPT,
19 + SELECTED_MODEL,
20 },
23 - crate::constants::ChatStatus,
21 + ed_agent_tauri::EdState,
22 log::{error, info},
23 + tauri::State,
24 };
25
26 /// Switch Siti to the model identified by `model_id` (a HuggingFace repo id
@@ -34,7 +33,7 @@ use {
33 target_os = "windows"
34 ))]
35 #[tauri::command]
37 -pub async fn chat_set_model(app: AppHandle, model_id: String) -> Result<(), String> {
36 +pub async fn chat_set_model(state: State<'_, EdState>, model_id: String) -> Result<(), String> {
37 // ── 1. Validate & resolve the requested model ────────────────────────
38 let display_name = resolve_model_id(&model_id)
39 .ok_or_else(|| format!("Unknown or unsupported model id: {model_id}"))?
@@ -43,7 +42,7 @@ pub async fn chat_set_model(app: AppHandle, model_id: String) -> Result<(), Stri
42 // ── 2. Persist the selection so resolved_model_config()/sampling_config() agree ─
43 // NB: never hold the std Mutex guard across an `.await` (it would make this
44 // command future `!Send`), so resolve the loaded-state first.
46 - let already_loaded = ENGINE.is_loaded().await;
45 + let already_loaded = state.ed().is_loaded().await;
46 {
47 let mut selected = SELECTED_MODEL
48 .lock()
@@ -56,33 +55,29 @@ pub async fn chat_set_model(app: AppHandle, model_id: String) -> Result<(), Stri
55 }
56
57 info!("chat_set_model: switching to {display_name} ({model_id})");
59 - emit_chat_status(&app, ChatStatus::Loading, Some(&display_name), None);
58
59 // ── 3. Swap models off-thread; report progress via events ────────────
60 // Re-resolve from the (now-persisted) selection inside the task so the
61 // GGUF/ISQ dispatch matches exactly what `chat_send_message` would load.
62 + let ed = state.shared();
63 tokio::task::spawn(async move {
65 - if ENGINE.is_loaded().await {
66 - ENGINE.unload_model().await;
64 + if ed.is_loaded().await {
65 + // The engine's own unload: Ed's `unload` would report `Unloaded`
66 + // and flash the UI before the load below reports `Loading`.
67 + ed.engine().unload_model().await;
68 }
69
69 - let result = resolved_model_config()
70 + // Ed reports `Loading`, then `Ready` or `Error`.
71 + match resolved_model_config()
72 .load(
73 + &ed,
74 Some(CHAT_SYSTEM_PROMPT.to_string()),
75 Some(sampling_config()),
76 )
74 - .await;
75 -
76 - match result {
77 - Ok(elapsed) => {
78 - info!("Model {display_name} loaded in {}", fmt_duration(elapsed));
79 - emit_chat_status(&app, ChatStatus::Ready, Some(&display_name), None);
80 - }
81 - Err(e) => {
82 - let msg = format!("Failed to load model {display_name}: {e}");
83 - error!("{msg}");
84 - emit_chat_status(&app, ChatStatus::Error, Some(&display_name), Some(&msg));
85 - }
77 + .await
78 + {
79 + Ok(elapsed) => info!("Model {display_name} loaded in {}", fmt_duration(elapsed)),
80 + Err(e) => error!("Failed to load model {display_name}: {e}"),
81 }
82 });
83
@@ -96,7 +91,7 @@ pub async fn chat_set_model(app: AppHandle, model_id: String) -> Result<(), Stri
91 target_os = "windows"
92 )))]
93 #[tauri::command]
99 -pub async fn chat_set_model(_app: AppHandle, _model_id: String) -> Result<(), String> {
94 +pub async fn chat_set_model(_model_id: String) -> Result<(), String> {
95 log::debug!("Model switching is not supported on this platform.");
96 Err("Model switching is not supported on this platform.".to_string())
97 }
src-tauri/src/chat/command_unload_model.rs
+7 -11
@@ -1,6 +1,6 @@
1 //! Tauri command `chat_unload_model`: frees the chat model from memory.
2
3 -use {log::debug, tauri::AppHandle};
3 +use log::debug;
4
5 #[cfg(any(
6 target_os = "macos",
@@ -8,11 +8,7 @@ use {log::debug, tauri::AppHandle};
8 target_os = "android",
9 target_os = "windows"
10 ))]
11 -use {
12 - super::{emit_chat_status, resolved_model_config, ENGINE},
13 - crate::constants::ChatStatus,
14 - log::info,
15 -};
11 +use {super::resolved_model_config, ed_agent_tauri::EdState, log::info, tauri::State};
12
13 /// Unload the chat model from memory to free resources.
14 #[cfg(any(
@@ -22,12 +18,12 @@ use {
18 target_os = "windows"
19 ))]
20 #[tauri::command]
25 -pub async fn chat_unload_model(app: AppHandle) -> Result<String, String> {
26 - if ENGINE.is_loaded().await {
21 +pub async fn chat_unload_model(state: State<'_, EdState>) -> Result<String, String> {
22 + if state.ed().is_loaded().await {
23 let display_name = resolved_model_config().display_name();
28 - ENGINE.unload_model().await;
24 + // Ed reports the `Unloaded` transition.
25 + state.ed().unload().await;
26 info!("Chat model unloaded: {}", display_name);
30 - emit_chat_status(&app, ChatStatus::Unloaded, None, None);
27 Ok(format!("Chat model {} unloaded.", display_name))
28 } else {
29 debug!("No chat model was loaded.");
@@ -42,7 +38,7 @@ pub async fn chat_unload_model(app: AppHandle) -> Result<String, String> {
38 target_os = "windows"
39 )))]
40 #[tauri::command]
45 -pub async fn chat_unload_model(_app: AppHandle) -> Result<String, String> {
41 +pub async fn chat_unload_model() -> Result<String, String> {
42 debug!("Chat model is not supported on this platform.");
43 Ok("No chat model was loaded.".to_string())
44 }
src-tauri/src/chat/mod.rs
+24 -94
@@ -4,17 +4,16 @@
4 //! the cached engine instance, model/sampling configuration, and helpers are
5 //! kept here in `mod.rs` so every command file can `use super::*`.
6 //!
7 -//! Model loading, inference, and history management are delegated to
8 -//! [`onde::inference::ChatEngine`], the shared on-device inference engine
9 -//! from the `onde` crate. Siti runs the engine **fully offline**: it loads
7 +//! Model loading, inference, history and streaming are delegated to
8 +//! [`ed_agent::Ed`], the agent SDK over the shared on-device inference engine
9 +//! from the `onde` crate. Ed lives in a managed [`EdState`] and reports to the
10 +//! webview through `ed-agent-tauri`'s events. Siti runs the engine **fully
11 +//! offline**: it loads
12 //! the platform-default GGUF model directly (no operator-assigned model, no
13 //! app credentials) and pulse telemetry is disabled during app setup.
14
15 // ── Command submodules (one Tauri command per file) ──────────────────────────
16
15 -pub mod command_clear_history;
16 -pub mod command_get_history;
17 -pub mod command_get_status;
17 pub mod command_list_models;
18 pub mod command_load_model;
19 pub mod command_remove_model;
@@ -24,9 +23,6 @@ pub mod command_unload_model;
23
24 // ── Re-exports so lib.rs can pull in commands with a flat path ───────────────
25
27 -pub use command_clear_history::chat_clear_history;
28 -pub use command_get_history::chat_get_history;
29 -pub use command_get_status::chat_get_status;
26 pub use command_list_models::chat_list_models;
27 pub use command_load_model::chat_load_model;
28 pub use command_remove_model::chat_remove_model;
@@ -36,10 +32,7 @@ pub use command_unload_model::chat_unload_model;
32
33 // ── Imports shared with submodules via `super::` ─────────────────────────────
34
39 -use {
40 - crate::constants::ChatStatus,
41 - serde::{Deserialize, Serialize},
42 -};
35 +use serde::{Deserialize, Serialize};
36
37 #[cfg(any(
38 target_os = "macos",
@@ -48,11 +41,9 @@ use {
41 target_os = "windows"
42 ))]
43 use {
51 - crate::events::{EVENT_CHAT_REPLY, EVENT_CHAT_STATUS_CHANGED},
52 - log::error,
44 + ed_agent::{Ed, GgufModelConfig, InferenceError, SamplingConfig},
45 + ed_agent_tauri::TauriSink,
46 once_cell::sync::Lazy,
54 - onde::inference::{ChatEngine, GgufModelConfig, InferenceError, SamplingConfig},
55 - tauri::{AppHandle, Emitter},
47 };
48
49 // `IsqModelConfig` and the ISQ load path only exist on macOS (Metal). Gemma is
@@ -62,45 +53,6 @@ use onde::inference::IsqModelConfig;
53
54 // ── Response / payload types ─────────────────────────────────────────────────
55
65 -/// Payload emitted with the `chat_status_changed` event.
66 -#[derive(Debug, Clone, Serialize, Deserialize)]
67 -pub struct ChatStatusPayload {
68 - pub status: ChatStatus,
69 - pub model_name: Option<String>,
70 - pub error: Option<String>,
71 -}
72 -
73 -/// A single message in the conversation, returned to the frontend.
74 -#[derive(Debug, Clone, Serialize, Deserialize)]
75 -pub struct ChatMessagePayload {
76 - pub role: String,
77 - pub content: String,
78 -}
79 -
80 -/// Event payload emitted as `chat_reply` once inference completes.
81 -///
82 -/// Using an event instead of a blocking command return prevents the WebView
83 -/// from garbage-collecting the JS invoke-callback before the slow on-device
84 -/// inference finishes.
85 -#[derive(Debug, Clone, Serialize, Deserialize)]
86 -pub struct ChatReplyPayload {
87 - /// The assistant's reply text, or `None` on error.
88 - pub reply: Option<String>,
89 - /// Human-readable inference duration, or `None` on error.
90 - pub duration: Option<String>,
91 - /// Error message if inference failed, or `None` on success.
92 - pub error: Option<String>,
93 -}
94 -
95 -/// Response returned by `chat_get_status`.
96 -#[derive(Debug, Clone, Serialize, Deserialize)]
97 -pub struct ChatStatusResponse {
98 - pub status: ChatStatus,
99 - pub model_name: Option<String>,
100 - pub approx_memory: Option<String>,
101 - pub history_length: usize,
102 -}
103 -
56 /// A selectable on-device model, returned by `chat_list_models` and rendered
57 /// in the Settings model dropdown.
58 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -123,18 +75,12 @@ pub struct ModelInfo {
75 pub is_selected: bool,
76 }
77
126 -// ── Shared ChatEngine instance ───────────────────────────────────────────────
78 +// ── The agent ────────────────────────────────────────────────────────────────
79 //
128 -// `ChatEngine` from `onde` handles all model lifecycle, history, and inference.
129 -// It is `Send + Sync` and manages its own internal mutex.
130 -
131 -#[cfg(any(
132 - target_os = "macos",
133 - target_os = "ios",
134 - target_os = "android",
135 - target_os = "windows"
136 -))]
137 -pub(crate) static ENGINE: Lazy<ChatEngine> = Lazy::new(ChatEngine::new);
80 +// One `Ed<TauriSink>` lives in managed `EdState` (see `lib.rs`). Ed owns the
81 +// `ChatEngine`, reports load transitions as `chat_status_changed`, streams
82 +// tokens as `chat_text_delta` and delivers replies as `chat_reply`. Commands
83 +// reach it through `State<EdState>`.
84
85 // ── Model selection & configuration ──────────────────────────────────────────
86 //
@@ -286,17 +232,24 @@ impl ResolvedModel {
232 }
233 }
234
289 - /// Load the resolved model into the shared [`ENGINE`], dispatching to the
290 - /// GGUF or ISQ load path as appropriate.
235 + /// Load the resolved model into `ed`, dispatching to the GGUF or ISQ load
236 + /// path as appropriate. Ed reports `Loading` then `Ready` or `Error`.
237 pub(crate) async fn load(
238 self,
239 + ed: &Ed<TauriSink>,
240 system_prompt: Option<String>,
241 sampling: Option<SamplingConfig>,
242 ) -> Result<std::time::Duration, InferenceError> {
243 match self {
297 - ResolvedModel::Gguf(c) => ENGINE.load_gguf_model(c, system_prompt, sampling).await,
244 + ResolvedModel::Gguf(c) => ed.load(c, system_prompt, sampling).await,
245 #[cfg(target_os = "macos")]
299 - ResolvedModel::Isq(c) => ENGINE.load_isq_model(c, system_prompt, sampling).await,
246 + ResolvedModel::Isq(c) => {
247 + let name = c.display_name.clone();
248 + ed.load_with(&name, |engine| {
249 + engine.load_isq_model(c, system_prompt, sampling)
250 + })
251 + .await
252 + }
253 }
254 }
255 }
@@ -469,26 +422,3 @@ pub(crate) fn fmt_duration(d: std::time::Duration) -> String {
422 format!("{:.1}s", secs)
423 }
424 }
472 -
473 -/// Emit a chat status event to the frontend.
474 -#[cfg(any(
475 - target_os = "macos",
476 - target_os = "ios",
477 - target_os = "android",
478 - target_os = "windows"
479 -))]
480 -pub(crate) fn emit_chat_status(
481 - app: &AppHandle,
482 - status: ChatStatus,
483 - model_name: Option<&str>,
484 - error_msg: Option<&str>,
485 -) {
486 - let payload = ChatStatusPayload {
487 - status,
488 - model_name: model_name.map(|s| s.to_string()),
489 - error: error_msg.map(|s| s.to_string()),
490 - };
491 - if let Err(e) = app.emit(EVENT_CHAT_STATUS_CHANGED, &payload) {
492 - error!("Failed to emit chat status event: {:?}", e);
493 - }
494 -}
src-tauri/src/lib.rs
+17 -5
@@ -11,9 +11,12 @@ pub mod inference {
11
12 use app_info::app_build_version;
13 use chat::{
14 - chat_clear_history, chat_get_history, chat_get_status, chat_list_models, chat_load_model,
15 - chat_remove_model, chat_send_message, chat_set_model, chat_unload_model,
14 + chat_list_models, chat_load_model, chat_remove_model, chat_send_message, chat_set_model,
15 + chat_unload_model,
16 };
17 +use ed_agent::Ed;
18 +use ed_agent_tauri::{EdState, TauriSink};
19 +use tauri::Manager;
20
21 #[cfg_attr(mobile, tauri::mobile_entry_point)]
22 pub fn run() {
@@ -38,16 +41,25 @@ pub fn run() {
41 // Disable telemetry + redirect the model cache into the shared
42 // App Group container. Must run before any onde / hf-hub code.
43 setup::setup(app)?;
44 +
45 + // Ed owns the engine; it reports status, text deltas and replies
46 + // to the webview through `TauriSink`. Created after `setup` so the
47 + // pulse switch and cache redirect are in place first.
48 + app.manage(EdState::new(Ed::with_sink(TauriSink::new(
49 + app.handle().clone(),
50 + ))));
51 Ok(())
52 })
53 .invoke_handler(tauri::generate_handler![
54 // Chat commands: fully on-device LLM inference
55 chat_load_model,
56 chat_unload_model,
47 - chat_get_status,
57 chat_send_message,
49 - chat_clear_history,
50 - chat_get_history,
58 + // Status and history come straight from the SDK. The command macros
59 + // need the full path.
60 + ed_agent_tauri::chat_get_status,
61 + ed_agent_tauri::chat_clear_history,
62 + ed_agent_tauri::chat_get_history,
63 chat_list_models,
64 chat_set_model,
65 chat_remove_model,
src-tauri/src/setup/mod.rs
+3 -2
@@ -6,12 +6,13 @@ use tauri::App;
6 /// Run all setup steps. Called from `tauri::Builder::setup()`.
7 ///
8 /// Both steps must run **before** any `onde` / `hf-hub` / `mistral.rs` code,
9 -/// i.e. before the chat `ENGINE` lazily initialises.
9 +/// i.e. before the managed `EdState` is created.
10 pub fn setup(app: &App) -> Result<(), Box<dyn std::error::Error>> {
11 // Siti runs fully offline. Disable onde's pulse telemetry so no usage
12 // beacons are ever sent, regardless of whether GresIQ credentials happen
13 // to be embedded in the onde build. `ChatEngine` reads this env var when
14 - // it lazily initialises its (optional) pulse client.
14 + // it initialises its (optional) pulse client, which now happens when Ed
15 + // creates the engine.
16 std::env::set_var("ONDE_DISABLE_PULSE", "1");
17
18 // Redirect HF_HOME, HF_HUB_CACHE, and TMPDIR into the shared App Group
src/App.tsx
+39 -11
@@ -12,6 +12,7 @@ import {
12 getHistory,
13 onStatusChanged,
14 onReply,
15 + onTextDelta,
16 } from "./api";
17
18 type Role = "user" | "assistant";
@@ -78,6 +79,9 @@ export default function App() {
79 const textareaRef = useRef<HTMLTextAreaElement>(null);
80 // Backend present? false when running in a plain browser (vite preview).
81 const backendRef = useRef(true);
82 + // Message id of the assistant bubble being filled by `chat_text_delta`
83 + // events, or null when no reply is streaming.
84 + const streamingRef = useRef<number | null>(null);
85
86 const refreshModels = useCallback(async () => {
87 try {
@@ -91,6 +95,7 @@ export default function App() {
95 useEffect(() => {
96 let unStatus: (() => void) | undefined;
97 let unReply: (() => void) | undefined;
98 + let unDelta: (() => void) | undefined;
99
100 (async () => {
101 try {
@@ -99,9 +104,12 @@ export default function App() {
104 setStatusModel(s.model_name);
105
106 const history = await getHistory();
102 - if (history.length) {
107 + const visible = history.filter(
108 + (m): m is typeof m & { role: Role } => m.role !== "system"
109 + );
110 + if (visible.length) {
111 setMessages(
104 - history.map((m) => ({
112 + visible.map((m) => ({
113 id: nextId++,
114 role: m.role,
115 text: m.content,
@@ -120,21 +128,40 @@ export default function App() {
128 setStatusError(p.error);
129 if (p.status === "ready" || p.status === "error") refreshModels();
130 });
123 - unReply = await onReply((p) => {
131 + unDelta = await onTextDelta((p) => {
132 + // The first delta ends the typing indicator and opens the bubble.
133 setThinking(false);
125 - if (p.reply) {
134 + if (streamingRef.current === null) {
135 + const id = nextId++;
136 + streamingRef.current = id;
137 setMessages((prev) => [
138 ...prev,
128 - { id: nextId++, role: "assistant", text: p.reply as string },
139 + { id, role: "assistant", text: p.delta },
140 ]);
130 - } else if (p.error) {
141 + } else {
142 + const id = streamingRef.current;
143 + setMessages((prev) =>
144 + prev.map((m) =>
145 + m.id === id ? { ...m, text: m.text + p.delta } : m
146 + )
147 + );
148 + }
149 + });
150 + unReply = await onReply((p) => {
151 + setThinking(false);
152 + const text = p.reply ?? (p.error ? `⚠️ ${p.error}` : null);
153 + const streamed = streamingRef.current;
154 + streamingRef.current = null;
155 + if (text === null) return;
156 + if (streamed !== null) {
157 + // The streamed bubble becomes the final text (or the error).
158 + setMessages((prev) =>
159 + prev.map((m) => (m.id === streamed ? { ...m, text } : m))
160 + );
161 + } else {
162 setMessages((prev) => [
163 ...prev,
133 - {
134 - id: nextId++,
135 - role: "assistant",
136 - text: `⚠️ ${p.error}`,
137 - },
164 + { id: nextId++, role: "assistant", text },
165 ]);
166 }
167 });
@@ -146,6 +173,7 @@ export default function App() {
173 return () => {
174 unStatus?.();
175 unReply?.();
176 + unDelta?.();
177 };
178 }, [refreshModels]);
179
src/api.ts
+22 -4
@@ -24,7 +24,8 @@ export interface ModelInfo {
24 is_selected: boolean;
25 }
26
27 -export interface ChatStatusResponse {
27 +/** Snapshot of the engine, from `chat_get_status` (Ed's `EngineInfo`). */
28 +export interface EngineInfo {
29 status: ChatStatus;
30 model_name: string | null;
31 approx_memory: string | null;
@@ -37,14 +38,25 @@ export interface ChatStatusPayload {
38 error: string | null;
39 }
40
41 +/** `chat_reply`: the whole reply once generation ends. `id` matches the
42 + * `chat_text_delta` events of the same turn. */
43 export interface ChatReplyPayload {
44 + id: string;
45 + session: string;
46 reply: string | null;
47 duration: string | null;
48 error: string | null;
49 }
50
51 +/** `chat_text_delta`: one piece of the reply as it is generated. */
52 +export interface ChatTextDeltaPayload {
53 + id: string;
54 + session: string;
55 + delta: string;
56 +}
57 +
58 export interface ChatMessagePayload {
47 - role: "user" | "assistant";
59 + role: "system" | "user" | "assistant";
60 content: string;
61 }
62
@@ -61,12 +73,13 @@ export const removeModel = (modelId: string) =>
73 export const sendMessage = (message: string) =>
74 invoke<void>("chat_send_message", { message });
75
64 -export const getStatus = () => invoke<ChatStatusResponse>("chat_get_status");
76 +export const getStatus = () => invoke<EngineInfo>("chat_get_status");
77
78 export const getHistory = () =>
79 invoke<ChatMessagePayload[]>("chat_get_history");
80
69 -export const clearHistory = () => invoke<void>("chat_clear_history");
81 +/** Resolves to how many messages were dropped. */
82 +export const clearHistory = () => invoke<number>("chat_clear_history");
83
84 // ── App metadata ──────────────────────────────────────────────────────────
85
@@ -85,6 +98,11 @@ export const onStatusChanged = (cb: (p: ChatStatusPayload) => void): Promise<Unl
98 export const onReply = (cb: (p: ChatReplyPayload) => void): Promise<UnlistenFn> =>
99 listen<ChatReplyPayload>("chat_reply", (e) => cb(e.payload));
100
101 +export const onTextDelta = (
102 + cb: (p: ChatTextDeltaPayload) => void
103 +): Promise<UnlistenFn> =>
104 + listen<ChatTextDeltaPayload>("chat_text_delta", (e) => cb(e.payload));
105 +
106 // ── Helpers ───────────────────────────────────────────────────────────────
107
108 /** Format a byte count as a compact "1.9 GB" / "941 MB" string. */