main
rs 368 lines 12.7 KB
Raw
1 //! Several conversations over one Ed.
2
3 use std::collections::HashMap;
4 use std::future::Future;
5 use std::sync::atomic::{AtomicU64, Ordering};
6 use std::sync::Arc;
7 use std::time::Duration;
8
9 use ed_agent::{
10 AgentConfig, AgentError, AgentReply, ApprovalDecision, ChatMessage, Ed, RejectingExecutor,
11 SessionError, SessionMeta, SessionStore, ToolExecutor,
12 };
13 use log::{error, info, warn};
14 use tauri::{AppHandle, Emitter};
15 use tokio::sync::Mutex;
16
17 use crate::approvals::{TauriApprovals, DEFAULT_APPROVAL_TIMEOUT};
18 use crate::events::{SubmitReplyPayload, EVENT_CHAT_REPLY};
19 use crate::scope::TurnScope;
20 use crate::sink::TauriSink;
21
22 #[derive(Default)]
23 struct State {
24 active: Option<String>,
25 /// Histories of sessions that were switched away from, so switching back
26 /// doesn't hit the store.
27 cache: HashMap<String, Vec<ChatMessage>>,
28 }
29
30 struct Shared {
31 app: AppHandle,
32 ed: Arc<Ed<TauriSink>>,
33 store: Arc<dyn SessionStore>,
34 approvals: Arc<TauriApprovals>,
35 scope: TurnScope,
36 /// Bumped by [`EdSessions::reset`]. A turn captures it at dispatch and
37 /// refuses to save or emit if it changed by completion, so a reply that
38 /// finishes after logout or an account switch can't land in the next
39 /// account's storage.
40 generation: AtomicU64,
41 state: Mutex<State>,
42 error_message: Option<String>,
43 new_id: Box<dyn Fn() -> String + Send + Sync>,
44 }
45
46 /// Multi-session chat state: one [`Ed`], many conversations.
47 ///
48 /// Register it with `app.manage(sessions)` and the `chat_*_session` commands.
49 /// It owns which session is live in the engine, an in-memory cache, the
50 /// generation fence, and approvals; persistence stays with the host's
51 /// [`SessionStore`].
52 ///
53 /// Cheap to clone.
54 #[derive(Clone)]
55 pub struct EdSessions {
56 shared: Arc<Shared>,
57 }
58
59 /// Configuration for [`EdSessions`].
60 pub struct EdSessionsBuilder {
61 app: AppHandle,
62 store: Arc<dyn SessionStore>,
63 executor: Arc<dyn ToolExecutor>,
64 config: AgentConfig,
65 onde_app_id: Option<String>,
66 approval_timeout: Duration,
67 error_message: Option<String>,
68 new_id: Box<dyn Fn() -> String + Send + Sync>,
69 }
70
71 impl EdSessionsBuilder {
72 /// Run tools through `executor`. Defaults to rejecting every tool.
73 pub fn executor(mut self, executor: Arc<dyn ToolExecutor>) -> Self {
74 self.executor = executor;
75 self
76 }
77
78 pub fn config(mut self, config: AgentConfig) -> Self {
79 self.config = config;
80 self
81 }
82
83 /// Associate telemetry with an Onde app id.
84 pub fn onde_app_id(mut self, id: impl Into<String>) -> Self {
85 self.onde_app_id = Some(id.into());
86 self
87 }
88
89 /// How long an approval waits for the user. Default 120 seconds.
90 pub fn approval_timeout(mut self, timeout: Duration) -> Self {
91 self.approval_timeout = timeout;
92 self
93 }
94
95 /// Show this to the user instead of the error text when a turn fails. The
96 /// real error is still logged.
97 pub fn error_message(mut self, message: impl Into<String>) -> Self {
98 self.error_message = Some(message.into());
99 self
100 }
101
102 /// How [`EdSessions::new_session`] names a session. Defaults to a random UUID. A store that
103 /// treats some ids as ephemeral (by prefix, say) can mint matching ones here.
104 pub fn id_generator(mut self, generator: impl Fn() -> String + Send + Sync + 'static) -> Self {
105 self.new_id = Box::new(generator);
106 self
107 }
108
109 pub fn build(self) -> EdSessions {
110 let scope = TurnScope::default();
111 let approvals = Arc::new(TauriApprovals::scoped(
112 self.app.clone(),
113 scope.clone(),
114 self.approval_timeout,
115 ));
116 let sink = TauriSink::scoped(self.app.clone(), scope.clone(), false);
117 let ed = Ed::with_agent_and_app_id(
118 sink,
119 self.executor,
120 approvals.clone(),
121 self.config,
122 self.onde_app_id,
123 );
124 EdSessions {
125 shared: Arc::new(Shared {
126 app: self.app,
127 ed: Arc::new(ed),
128 store: self.store,
129 approvals,
130 scope,
131 generation: AtomicU64::new(0),
132 state: Mutex::new(State::default()),
133 error_message: self.error_message,
134 new_id: self.new_id,
135 }),
136 }
137 }
138 }
139
140 impl EdSessions {
141 pub fn builder(app: AppHandle, store: impl SessionStore) -> EdSessionsBuilder {
142 EdSessionsBuilder {
143 app,
144 store: Arc::new(store),
145 executor: Arc::new(RejectingExecutor),
146 config: AgentConfig::default(),
147 onde_app_id: None,
148 approval_timeout: DEFAULT_APPROVAL_TIMEOUT,
149 error_message: None,
150 new_id: Box::new(|| uuid::Uuid::new_v4().to_string()),
151 }
152 }
153
154 /// The agent, for loading a model and anything else Ed exposes.
155 pub fn ed(&self) -> &Arc<Ed<TauriSink>> {
156 &self.shared.ed
157 }
158
159 pub fn approvals(&self) -> &Arc<TauriApprovals> {
160 &self.shared.approvals
161 }
162
163 /// The current generation. Capture it before slow work and compare with
164 /// [`is_current`](EdSessions::is_current) afterwards.
165 pub fn generation(&self) -> u64 {
166 self.shared.generation.load(Ordering::SeqCst)
167 }
168
169 pub fn is_current(&self, generation: u64) -> bool {
170 self.generation() == generation
171 }
172
173 /// The id of the session live in the engine, if any.
174 pub async fn active(&self) -> Option<String> {
175 self.shared.state.lock().await.active.clone()
176 }
177
178 /// Stored sessions, most recently updated first.
179 pub async fn list(&self) -> Result<Vec<SessionMeta>, SessionError> {
180 self.shared.store.list().await
181 }
182
183 /// Make `session` the live conversation.
184 ///
185 /// The outgoing session's history is kept in memory; the incoming one
186 /// comes from memory, then the store. An unknown id starts empty.
187 pub async fn activate(&self, session: &str) {
188 let shared = &self.shared;
189 let mut state = shared.state.lock().await;
190 if state.active.as_deref() == Some(session) {
191 return;
192 }
193 if let Some(previous) = state.active.take() {
194 let live = shared.ed.history().await;
195 state.cache.insert(previous, live);
196 }
197 let stored = match state.cache.remove(session) {
198 Some(messages) => messages,
199 None => match shared.store.load(session).await {
200 Ok(messages) => messages.unwrap_or_default(),
201 Err(err) => {
202 error!("ed-tauri: could not load session {session}: {err}");
203 Vec::new()
204 }
205 },
206 };
207 shared.ed.restore_history(stored).await;
208 state.active = Some(session.to_owned());
209 }
210
211 /// Start an empty session and make it live. Returns its id. The store
212 /// hears about it when the first turn completes.
213 pub async fn new_session(&self) -> String {
214 let id = (self.shared.new_id)();
215 self.activate(&id).await;
216 id
217 }
218
219 /// The messages of `session`, activating it.
220 pub async fn history(&self, session: &str) -> Vec<ChatMessage> {
221 self.activate(session).await;
222 self.shared.ed.history().await
223 }
224
225 /// The messages of `session` without switching to it: the live history if it is the active
226 /// session, else the cache, else the store. For rendering a session that is not on screen,
227 /// without disturbing a turn that is running in another.
228 pub async fn peek(&self, session: &str) -> Vec<ChatMessage> {
229 let shared = &self.shared;
230 let state = shared.state.lock().await;
231 if state.active.as_deref() == Some(session) {
232 return shared.ed.history().await;
233 }
234 if let Some(cached) = state.cache.get(session) {
235 return cached.clone();
236 }
237 drop(state);
238 match shared.store.load(session).await {
239 Ok(messages) => messages.unwrap_or_default(),
240 Err(err) => {
241 error!("ed-tauri: could not load session {session}: {err}");
242 Vec::new()
243 }
244 }
245 }
246
247 /// Delete a session from the store and memory. If it was live, the engine
248 /// is cleared and no session is live until the next activate.
249 pub async fn delete(&self, session: &str) -> Result<(), SessionError> {
250 let shared = &self.shared;
251 let result = shared.store.delete(session).await;
252 let mut state = shared.state.lock().await;
253 state.cache.remove(session);
254 if state.active.as_deref() == Some(session) {
255 state.active = None;
256 shared.ed.clear_history().await;
257 }
258 result
259 }
260
261 /// Drop all in-memory state and fence off in-flight turns. For logout and
262 /// account switches: the next account must never see this one's chats.
263 /// Stored sessions are left alone.
264 pub async fn reset(&self) {
265 let shared = &self.shared;
266 shared.generation.fetch_add(1, Ordering::SeqCst);
267 shared.ed.cancel();
268 shared.approvals.cancel_all().await;
269 let mut state = shared.state.lock().await;
270 state.cache.clear();
271 state.active = None;
272 shared.ed.clear_history().await;
273 }
274
275 /// Save the live history under `session`, unless `generation` is stale.
276 pub async fn save(&self, session: &str, generation: u64) {
277 let shared = &self.shared;
278 let live = shared.ed.history().await;
279 let mut state = shared.state.lock().await;
280 if !self.is_current(generation) {
281 return;
282 }
283 if let Err(err) = shared.store.save(session, &live).await {
284 error!("ed-tauri: could not save session {session}: {err}");
285 }
286 state.cache.insert(session.to_owned(), live);
287 }
288
289 /// Run one agent turn in `session` and deliver the result as a
290 /// `chat_reply` event.
291 ///
292 /// Returns as soon as the session is active and the turn is spawned. A
293 /// long-pending invoke can be dropped by a mobile webview, so the answer
294 /// travels by event: `{id, session, reply, duration, error}`.
295 pub async fn submit(&self, session: &str, id: &str, message: impl Into<String>) {
296 let message = message.into();
297 self.submit_with(session, id, move |ed| async move { ed.run(message).await })
298 .await;
299 }
300
301 /// Like [`submit`](EdSessions::submit), with the turn supplied by the
302 /// host: rewrite the message, or recover and retry on a known engine
303 /// failure. The closure runs on the spawned task.
304 pub async fn submit_with<F, Fut>(&self, session: &str, id: &str, turn: F)
305 where
306 F: FnOnce(Arc<Ed<TauriSink>>) -> Fut + Send + 'static,
307 Fut: Future<Output = Result<AgentReply, AgentError>> + Send + 'static,
308 {
309 self.activate(session).await;
310 let generation = self.generation();
311 let this = self.clone();
312 let session = session.to_owned();
313 let id = id.to_owned();
314 info!("ed-tauri: dispatch session={session} id={id}");
315
316 tokio::spawn(async move {
317 this.shared.scope.set(&session, &id);
318 let result = turn(this.shared.ed.clone()).await;
319 this.shared.scope.clear();
320
321 if !this.is_current(generation) {
322 info!("ed-tauri: reply dropped, the account context changed (session={session})");
323 return;
324 }
325 this.save(&session, generation).await;
326 if !this.is_current(generation) {
327 return;
328 }
329
330 let payload = match result {
331 Ok(reply) => SubmitReplyPayload {
332 id,
333 session,
334 reply: Some(reply.text),
335 duration: Some(reply.duration),
336 error: None,
337 },
338 Err(err) => {
339 error!("ed-tauri: turn failed: {err}");
340 SubmitReplyPayload {
341 id,
342 session,
343 reply: None,
344 duration: None,
345 error: Some(
346 this.shared
347 .error_message
348 .clone()
349 .unwrap_or_else(|| err.to_string()),
350 ),
351 }
352 }
353 };
354 if let Err(err) = this.shared.app.emit(EVENT_CHAT_REPLY, payload) {
355 warn!("ed-tauri: could not emit chat_reply: {err}");
356 }
357 });
358 }
359
360 /// Answer a pending approval.
361 pub async fn respond_approval(
362 &self,
363 request_id: &str,
364 decision: ApprovalDecision,
365 ) -> Result<(), String> {
366 self.shared.approvals.respond(request_id, decision).await
367 }
368 }