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. */