Skip to main content

ai_ui_core/local/
mod.rs

1//! In-memory thread runtime driving a [`ChatModelAdapter`], ported from
2//! `runtimes/local/local-thread-runtime-core.ts`.
3//!
4//! Methods change state synchronously and notify subscribers before they
5//! return; a method that starts a model run also returns a [`RunFuture`] that
6//! the host spawns on its executor (for example `slint::spawn_local`). Dropping
7//! that future cancels its run, even before the first poll, and aborts any
8//! in-flight adapter or tool through its signal.
9
10mod composer;
11mod continuation;
12mod run;
13mod tools;
14
15pub use composer::Composer;
16pub use tools::{Tool, ToolContext, ToolFuture, ToolRegistry};
17
18pub use continuation::{should_continue, with_continuation, with_external_results};
19
20use crate::abort::AbortController;
21use crate::adapter::{ChatModelAdapter, ModelContext};
22use crate::repository::{ExportedRepository, MessageChanges, MessageEntry, MessageRepository};
23use crate::runtime::{
24    ComposerHost, RunFuture, RuntimeCapabilities, RuntimeError, StartRunConfig, ThreadRuntime,
25    ToolResult,
26};
27use crate::subscribe::{Subscribers, Subscription};
28use crate::types::{
29    AppendContent, AppendMessage, AssistantMessage, AssistantMetadata, MessageId, MessageMetadata,
30    MessageStatus, RequiresActionReason, RunConfig, SystemMessage, SystemMessagePart, TextPart,
31    ThreadMessage, Timestamp, UserMessage,
32};
33use std::cell::{Cell, RefCell};
34use std::collections::HashMap;
35use std::rc::{Rc, Weak};
36use std::sync::Arc;
37
38/// Construction options (`LocalRuntimeOptions`).
39#[derive(Clone)]
40pub struct LocalRuntimeOptions {
41    /// Model steps per run before the run stops with pending tool calls
42    /// (`maxSteps`, default 2). Steps are the adapter's reported `steps` or,
43    /// if it reports none, the number of steps the runtime started.
44    pub max_steps: usize,
45    /// Tool calls the frontend answers (`unstable_humanToolNames`); see
46    /// [`should_continue`].
47    pub human_tool_names: Option<Vec<String>>,
48    /// Passed to the adapter as `thread_id`.
49    pub thread_id: Option<String>,
50    /// Message timestamps.
51    pub clock: Rc<dyn Fn() -> Timestamp>,
52    /// Message ids; the runtime skips any id already stored.
53    pub ids: Rc<dyn Fn() -> MessageId>,
54}
55
56impl LocalRuntimeOptions {
57    /// Options with an explicit clock and ids derived from it.
58    pub fn with_clock(clock: Rc<dyn Fn() -> Timestamp>) -> Self {
59        let prefix = format!("{:x}", clock().0);
60        let counter = Cell::new(0u64);
61        Self {
62            max_steps: 2,
63            human_tool_names: None,
64            thread_id: None,
65            clock,
66            ids: Rc::new(move || {
67                counter.set(counter.get() + 1);
68                MessageId(format!("{prefix}-{}", counter.get()))
69            }),
70        }
71    }
72}
73
74/// Uses the system clock, which `wasm32-unknown-unknown` lacks: browser hosts
75/// use [`LocalRuntimeOptions::with_clock`] with `Date.now()`.
76#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
77impl Default for LocalRuntimeOptions {
78    fn default() -> Self {
79        Self::with_clock(Rc::new(|| {
80            let now = std::time::SystemTime::now()
81                .duration_since(std::time::UNIX_EPOCH)
82                .unwrap_or_default();
83            Timestamp(now.as_millis() as u64)
84        }))
85    }
86}
87
88struct ActiveRun {
89    id: u64,
90    /// The current step's controller, while a step is in flight.
91    controller: Option<AbortController>,
92    /// Cancelled before a step started; the next step does not start.
93    cancelled: bool,
94}
95
96struct State {
97    repository: MessageRepository,
98    active: Option<ActiveRun>,
99    next_run: u64,
100    model_context: ModelContext,
101    tools: Rc<ToolRegistry>,
102}
103
104pub(crate) struct Shared {
105    me: Weak<Shared>,
106    state: RefCell<State>,
107    adapter: Rc<dyn ChatModelAdapter>,
108    options: LocalRuntimeOptions,
109    subscribers: Rc<Subscribers>,
110    thread_composer: RefCell<Option<Composer>>,
111    edits: RefCell<HashMap<MessageId, Composer>>,
112}
113
114impl Shared {
115    /// Notify runtime subscribers, then composers (whose `can_send` and
116    /// `can_cancel` can depend on runtime state).
117    fn notify(&self) {
118        self.subscribers.notify();
119        let composers: Vec<Composer> = self
120            .thread_composer
121            .borrow()
122            .iter()
123            .cloned()
124            .chain(self.edits.borrow().values().cloned())
125            .collect();
126        for composer in composers {
127            composer.notify();
128        }
129    }
130    fn is_active(&self, run: u64) -> bool {
131        self.state
132            .borrow()
133            .active
134            .as_ref()
135            .is_some_and(|a| a.id == run)
136    }
137    fn fresh_id(&self, repository: &MessageRepository) -> MessageId {
138        loop {
139            let id = (self.options.ids)();
140            if !repository.contains(&id) {
141                return id;
142            }
143        }
144    }
145}
146
147/// A single thread with its branches, driving one model adapter.
148#[derive(Clone)]
149pub struct LocalThreadRuntime {
150    shared: Rc<Shared>,
151}
152
153impl LocalThreadRuntime {
154    /// A runtime that runs `adapter` for each message sent.
155    pub fn new(adapter: impl ChatModelAdapter + 'static, options: LocalRuntimeOptions) -> Self {
156        Self {
157            shared: Rc::new_cyclic(|me| Shared {
158                me: me.clone(),
159                state: RefCell::new(State {
160                    repository: MessageRepository::new(),
161                    active: None,
162                    next_run: 0,
163                    model_context: ModelContext::default(),
164                    tools: Rc::default(),
165                }),
166                adapter: Rc::new(adapter),
167                options,
168                subscribers: Rc::default(),
169                thread_composer: RefCell::new(None),
170                edits: RefCell::new(HashMap::new()),
171            }),
172        }
173    }
174
175    /// Called after every change. Drop the guard to unsubscribe.
176    pub fn subscribe(&self, callback: impl Fn() + 'static) -> Subscription {
177        self.shared.subscribers.subscribe(callback)
178    }
179
180    /// The selected branch, root first. Cheap to call; cached between changes.
181    pub fn messages(&self) -> Arc<[Arc<ThreadMessage>]> {
182        self.shared.state.borrow().repository.messages()
183    }
184    /// Whether a run is in progress.
185    pub fn is_running(&self) -> bool {
186        self.shared.state.borrow().active.is_some()
187    }
188    /// A message and its position, by id.
189    pub fn message(&self, id: &MessageId) -> Result<MessageEntry, RuntimeError> {
190        Ok(self.shared.state.borrow().repository.get(id)?)
191    }
192    /// The message and its siblings, for a branch picker.
193    pub fn branches(&self, id: &MessageId) -> Result<Vec<MessageId>, RuntimeError> {
194        Ok(self.shared.state.borrow().repository.branches(id)?.to_vec())
195    }
196    /// Select the branch containing `id`; subscribers are notified.
197    pub fn switch_to_branch(&self, id: &MessageId) -> Result<(), RuntimeError> {
198        self.shared
199            .state
200            .borrow_mut()
201            .repository
202            .switch_to_branch(id)?;
203        self.shared.notify();
204        Ok(())
205    }
206    /// The composer for the next message (`thread.composer`).
207    pub fn composer(&self) -> Composer {
208        self.shared
209            .thread_composer
210            .borrow_mut()
211            .get_or_insert_with(|| {
212                Composer::thread(Rc::downgrade(&self.shared) as Weak<dyn ComposerHost>)
213            })
214            .clone()
215    }
216
217    /// Start editing a message (`beginEdit`). Sending the edit composer adds
218    /// a sibling branch; cancelling discards it.
219    pub fn begin_edit(&self, id: &MessageId) -> Result<Composer, RuntimeError> {
220        if self.shared.edits.borrow().contains_key(id) {
221            return Err(RuntimeError::EditInProgress(id.clone()));
222        }
223        let entry = self.message(id)?;
224        let composer = Composer::edit(
225            Rc::downgrade(&self.shared) as Weak<dyn ComposerHost>,
226            entry.parent_id,
227            &entry.message,
228        );
229        self.shared
230            .edits
231            .borrow_mut()
232            .insert(id.clone(), composer.clone());
233        self.shared.notify();
234        Ok(composer)
235    }
236
237    /// The edit composer for a message, while it is being edited.
238    pub fn edit_composer(&self, id: &MessageId) -> Option<Composer> {
239        self.shared.edits.borrow().get(id).cloned()
240    }
241
242    /// Register the tools this thread offers. Their definitions are added to
243    /// the model context (replacing earlier tool definitions), and open calls
244    /// of tools with an `execute` function run in the app after each step.
245    pub fn set_tools(&self, tools: ToolRegistry) {
246        let mut state = self.shared.state.borrow_mut();
247        state.model_context.tools = tools.definitions();
248        state.tools = Rc::new(tools);
249    }
250
251    /// Record the user's answer to a pending tool approval (`respond to a
252    /// tool approval`). An approved call runs; a denied one answers the model
253    /// with an error. Returns the run that continues the message, if none is
254    /// in progress, for the host to spawn.
255    pub fn respond_to_tool_approval(
256        &self,
257        message_id: &MessageId,
258        approval_id: &str,
259        approved: bool,
260        reason: Option<String>,
261    ) -> Result<Option<RunFuture>, RuntimeError> {
262        let (resume, parent) = {
263            let mut state = self.shared.state.borrow_mut();
264            let entry = state.repository.get(message_id)?;
265            let Some(assistant) = entry.message.as_assistant() else {
266                return Err(RuntimeError::NotAssistantMessage(message_id.clone()));
267            };
268            let mut updated = assistant.clone();
269            let approval = updated
270                .content
271                .iter_mut()
272                .filter_map(|p| match p {
273                    crate::types::AssistantMessagePart::ToolCall(c) => c.approval.as_mut(),
274                    _ => None,
275                })
276                .find(|a| a.id == approval_id && a.is_pending())
277                .ok_or_else(|| RuntimeError::ApprovalNotFound(approval_id.to_owned()))?;
278            approval.approved = Some(approved);
279            approval.reason = reason;
280            let paused = matches!(
281                updated.status,
282                MessageStatus::RequiresAction {
283                    reason: RequiresActionReason::ToolCalls
284                }
285            );
286            let ready =
287                paused && state.active.is_none() && !state.repository.has_children(message_id);
288            state
289                .repository
290                .add_or_update(entry.parent_id.as_ref(), ThreadMessage::Assistant(updated))?;
291            let resume = ready.then(|| Self::activate(&mut state));
292            (resume, entry.parent_id)
293        };
294        self.shared.notify();
295        Ok(resume.map(|run| -> RunFuture {
296            Box::pin(run::run_loop(
297                self.shared.clone(),
298                run,
299                parent,
300                message_id.clone(),
301                RunConfig::default(),
302                true,
303            ))
304        }))
305    }
306
307    /// Replace the system prompt, tools and settings sent with later runs.
308    pub fn set_model_context(&self, context: ModelContext) {
309        self.shared.state.borrow_mut().model_context = context;
310    }
311
312    /// Record feedback on an assistant message (`submitFeedback`); `None`
313    /// clears it.
314    pub fn submit_feedback(
315        &self,
316        id: &MessageId,
317        feedback: Option<crate::types::SubmittedFeedback>,
318    ) -> Result<(), RuntimeError> {
319        {
320            let mut state = self.shared.state.borrow_mut();
321            let entry = state.repository.get(id)?;
322            let Some(assistant) = entry.message.as_assistant() else {
323                return Err(RuntimeError::NotAssistantMessage(id.clone()));
324            };
325            let mut updated = assistant.clone();
326            updated.metadata.submitted_feedback = feedback;
327            state
328                .repository
329                .add_or_update(entry.parent_id.as_ref(), ThreadMessage::Assistant(updated))?;
330        }
331        self.shared.notify();
332        Ok(())
333    }
334
335    /// Persisted messages and the selected head.
336    pub fn export(&self) -> ExportedRepository {
337        self.shared.state.borrow().repository.export()
338    }
339    /// Load history (for example from a thread history adapter).
340    pub fn import(&self, exported: ExportedRepository) -> Result<(), RuntimeError> {
341        {
342            let mut state = self.shared.state.borrow_mut();
343            if state.active.is_some() {
344                return Err(RuntimeError::RunInProgress);
345            }
346            state.repository.import(exported)?;
347        }
348        self.shared.notify();
349        Ok(())
350    }
351
352    /// Add a message. A user message (or `start_run: Some(true)`) starts a run
353    /// answering it, cancelling any run in progress. Editing is an append with
354    /// the edited message's parent and `source_id`.
355    pub fn append(&self, message: AppendMessage) -> Result<RunFuture, RuntimeError> {
356        let start = message.starts_run();
357        let id = {
358            let mut state = self.shared.state.borrow_mut();
359            let id = self.shared.fresh_id(&state.repository);
360            let created_at = (self.shared.options.clock)();
361            let new = match message.content {
362                AppendContent::User {
363                    content,
364                    attachments,
365                    custom,
366                } => ThreadMessage::User(UserMessage {
367                    id: id.clone(),
368                    created_at,
369                    content,
370                    attachments,
371                    metadata: MessageMetadata {
372                        custom,
373                        ..Default::default()
374                    },
375                }),
376                AppendContent::Assistant { content, custom } => {
377                    ThreadMessage::Assistant(AssistantMessage {
378                        id: id.clone(),
379                        created_at,
380                        content,
381                        status: MessageStatus::complete(),
382                        metadata: AssistantMetadata {
383                            custom,
384                            ..Default::default()
385                        },
386                    })
387                }
388                AppendContent::System { text } => ThreadMessage::System(SystemMessage {
389                    id: id.clone(),
390                    created_at,
391                    content: vec![SystemMessagePart::Text(TextPart::new(text))],
392                    metadata: MessageMetadata::default(),
393                }),
394            };
395            state
396                .repository
397                .add_or_update(message.parent_id.as_ref(), new)?;
398            if !start {
399                state.repository.switch_to_branch(&id)?;
400            }
401            id
402        };
403        if !start {
404            self.shared.notify();
405            return Ok(Box::pin(async { Ok(()) }));
406        }
407        self.start_run(StartRunConfig {
408            parent_id: Some(id),
409            source_id: message.source_id,
410            run_config: message.run_config,
411        })
412    }
413
414    /// Add a running assistant message after `parent_id` and answer it: a new
415    /// answer, or a regeneration when `source_id` names the previous one.
416    pub fn start_run(&self, config: StartRunConfig) -> Result<RunFuture, RuntimeError> {
417        let (run, message_id) = {
418            let mut state = self.shared.state.borrow_mut();
419            let id = self.shared.fresh_id(&state.repository);
420            let message = AssistantMessage {
421                id: id.clone(),
422                created_at: (self.shared.options.clock)(),
423                content: Vec::new(),
424                status: MessageStatus::Running,
425                metadata: AssistantMetadata::default(),
426            };
427            state
428                .repository
429                .add_or_update(config.parent_id.as_ref(), ThreadMessage::Assistant(message))?;
430            state.repository.switch_to_branch(&id)?;
431            (Self::activate(&mut state), id)
432        };
433        self.shared.notify();
434        Ok(Box::pin(run::run_loop(
435            self.shared.clone(),
436            run,
437            config.parent_id,
438            message_id,
439            config.run_config,
440            false,
441        )))
442    }
443
444    /// Cancel any run in progress; its message becomes `incomplete/cancelled`.
445    pub fn cancel_run(&self) {
446        let controller = self
447            .shared
448            .state
449            .borrow_mut()
450            .active
451            .as_mut()
452            .and_then(|a| {
453                a.cancelled = true;
454                a.controller.take()
455            });
456        if let Some(controller) = controller {
457            controller.abort();
458        }
459    }
460
461    /// Record a tool result. If that answers the last open call of a paused
462    /// message, the run continues and its future is returned.
463    pub fn add_tool_result(&self, result: ToolResult) -> Result<Option<RunFuture>, RuntimeError> {
464        let (resume, parent) = {
465            let mut state = self.shared.state.borrow_mut();
466            let entry = state.repository.get(&result.message_id)?;
467            let Some(assistant) = entry.message.as_assistant() else {
468                return Err(RuntimeError::NotAssistantMessage(result.message_id));
469            };
470            let mut updated = assistant.clone();
471            let mut added = false;
472            let call = updated
473                .content
474                .iter_mut()
475                .filter_map(|p| match p {
476                    crate::types::AssistantMessagePart::ToolCall(c) => Some(c),
477                    _ => None,
478                })
479                .find(|c| c.tool_call_id == result.tool_call_id)
480                .ok_or_else(|| RuntimeError::ToolCallNotFound {
481                    message_id: result.message_id.clone(),
482                    tool_call_id: result.tool_call_id.clone(),
483                })?;
484            added |= call.is_open();
485            call.result = Some(result.result);
486            call.is_error = Some(result.is_error);
487            call.is_preliminary = None;
488            if result.artifact.is_some() {
489                call.artifact = result.artifact;
490            }
491            let ready = added
492                && state.active.is_none()
493                && should_continue(&updated, self.shared.options.human_tool_names.as_deref())
494                && !state.repository.has_children(&result.message_id);
495            state
496                .repository
497                .add_or_update(entry.parent_id.as_ref(), ThreadMessage::Assistant(updated))?;
498            let resume = ready.then(|| Self::activate(&mut state));
499            (resume, entry.parent_id)
500        };
501        self.shared.notify();
502        Ok(resume.map(|run| -> RunFuture {
503            Box::pin(run::run_loop(
504                self.shared.clone(),
505                run,
506                parent,
507                result.message_id,
508                RunConfig::default(),
509                false,
510            ))
511        }))
512    }
513
514    /// Mark a new run active, cancelling the previous one.
515    fn activate(state: &mut State) -> u64 {
516        if let Some(previous) = state.active.take() {
517            if let Some(controller) = previous.controller {
518                controller.abort();
519            }
520        }
521        let id = state.next_run;
522        state.next_run += 1;
523        state.active = Some(ActiveRun {
524            id,
525            controller: None,
526            cancelled: false,
527        });
528        id
529    }
530}
531
532impl ComposerHost for Shared {
533    fn last_message_id(&self) -> Option<MessageId> {
534        self.state
535            .borrow()
536            .repository
537            .messages()
538            .last()
539            .map(|m| m.id().clone())
540    }
541    fn append(&self, message: AppendMessage) -> Result<RunFuture, RuntimeError> {
542        let shared = self.me.upgrade().ok_or(RuntimeError::Disposed)?;
543        LocalThreadRuntime { shared }.append(message)
544    }
545    fn cancel_run(&self) {
546        if let Some(shared) = self.me.upgrade() {
547            LocalThreadRuntime { shared }.cancel_run();
548        }
549    }
550    fn is_running(&self) -> bool {
551        self.state.borrow().active.is_some()
552    }
553    fn end_edit(&self, id: &MessageId) {
554        self.edits.borrow_mut().remove(id);
555        self.notify();
556    }
557}
558
559impl ThreadRuntime for LocalThreadRuntime {
560    fn messages(&self) -> Arc<[Arc<ThreadMessage>]> {
561        Self::messages(self)
562    }
563    fn message(&self, id: &MessageId) -> Result<MessageEntry, RuntimeError> {
564        Self::message(self, id)
565    }
566    fn is_running(&self) -> bool {
567        Self::is_running(self)
568    }
569    fn capabilities(&self) -> RuntimeCapabilities {
570        RuntimeCapabilities::ALL
571    }
572    fn branches(&self, id: &MessageId) -> Result<Vec<MessageId>, RuntimeError> {
573        Self::branches(self, id)
574    }
575    fn branch_position(&self, id: &MessageId) -> Result<(usize, usize), RuntimeError> {
576        Ok(self.shared.state.borrow().repository.branch_position(id)?)
577    }
578    fn structure_version(&self) -> Option<u64> {
579        Some(self.shared.state.borrow().repository.shape_version())
580    }
581    fn message_changes_since(&self, version: Option<u64>) -> Option<MessageChanges> {
582        Some(
583            self.shared
584                .state
585                .borrow()
586                .repository
587                .message_changes_since(version),
588        )
589    }
590    fn switch_to_branch(&self, id: &MessageId) -> Result<(), RuntimeError> {
591        Self::switch_to_branch(self, id)
592    }
593    fn append(&self, message: AppendMessage) -> Result<RunFuture, RuntimeError> {
594        Self::append(self, message)
595    }
596    fn reload(&self, config: StartRunConfig) -> Result<RunFuture, RuntimeError> {
597        self.start_run(config)
598    }
599    fn cancel_run(&self) {
600        Self::cancel_run(self)
601    }
602    fn add_tool_result(&self, result: ToolResult) -> Result<Option<RunFuture>, RuntimeError> {
603        Self::add_tool_result(self, result)
604    }
605    fn respond_to_tool_approval(
606        &self,
607        message_id: &MessageId,
608        approval_id: &str,
609        approved: bool,
610        reason: Option<String>,
611    ) -> Result<Option<RunFuture>, RuntimeError> {
612        Self::respond_to_tool_approval(self, message_id, approval_id, approved, reason)
613    }
614    fn submit_feedback(
615        &self,
616        id: &MessageId,
617        feedback: Option<crate::types::SubmittedFeedback>,
618    ) -> Result<(), RuntimeError> {
619        Self::submit_feedback(self, id, feedback)
620    }
621    fn composer(&self) -> Composer {
622        Self::composer(self)
623    }
624    fn begin_edit(&self, id: &MessageId) -> Result<Composer, RuntimeError> {
625        Self::begin_edit(self, id)
626    }
627    fn edit_composer(&self, id: &MessageId) -> Option<Composer> {
628        Self::edit_composer(self, id)
629    }
630    fn subscribe_dyn(&self, callback: Box<dyn Fn()>) -> Subscription {
631        self.shared.subscribers.subscribe(callback)
632    }
633    fn export(&self) -> ExportedRepository {
634        Self::export(self)
635    }
636    fn import(&self, exported: ExportedRepository) -> Result<(), RuntimeError> {
637        Self::import(self, exported)
638    }
639}