ai-ui-core 0.1.0

Rust port of @assistant-ui/core: message model, branching repository, model adapter contract and local thread runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
//! The thread list, after upstream's `ThreadListRuntimeCore` and its remote
//! variant: many threads, one of them the main thread.
//!
//! Every thread is any [`ThreadRuntime`] the `factory` creates, so a list can
//! hold local and external-store threads alike. Changes apply immediately
//! (optimistically) and, if a [`ThreadListAdapter`] is given, are persisted by
//! background tasks handed to the host's [`Spawner`]; a failed task undoes
//! its change and is reported through [`ThreadList::last_error`].
//!
//! A new thread becomes a regular thread (and is registered with the adapter)
//! when its first message appears. After the first answer completes, the
//! adapter may generate a title.

use crate::repository::ExportedRepository;
use crate::runtime::ThreadRuntime;
use crate::subscribe::{Subscribers, Subscription};
use crate::types::ThreadMessage;
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::pin::Pin;
use std::rc::{Rc, Weak};
use std::sync::Arc;

/// Runs background work on the host's executor (for example
/// `|f| { slint::spawn_local(f).ok(); }`).
pub type Spawner = Rc<dyn Fn(Pin<Box<dyn Future<Output = ()>>>)>;

/// The outcome of an adapter call; an error is a message for the user.
pub type AdapterFuture<T> = Pin<Box<dyn Future<Output = Result<T, String>>>>;

/// Where a thread is in its life.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ThreadStatus {
    /// Not yet registered: no message has been sent.
    New,
    /// A stored thread in the main list.
    Regular,
    /// A stored thread that is hidden from the main list.
    Archived,
}

/// One entry of the list.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadItem {
    /// Stable local id.
    pub id: String,
    /// The adapter's id, once known.
    pub remote_id: Option<String>,
    /// Whether the thread is new, regular or archived.
    pub status: ThreadStatus,
    /// The thread's title, once it has one.
    pub title: Option<String>,
}

/// A thread the adapter knows.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteThread {
    /// The adapter's id for the thread.
    pub remote_id: String,
    /// The thread's title, if stored.
    pub title: Option<String>,
    /// Whether the thread is archived.
    pub archived: bool,
}

/// Persistence for the list (`RemoteThreadListAdapter`). Every method has an
/// inert default, so an adapter implements only what it stores.
pub trait ThreadListAdapter {
    /// The stored threads, newest first.
    fn list(&self) -> AdapterFuture<Vec<RemoteThread>> {
        Box::pin(async { Ok(Vec::new()) })
    }
    /// Register a new thread; returns its remote id.
    fn initialize(&self, local_id: &str) -> AdapterFuture<String> {
        let id = local_id.to_owned();
        Box::pin(async move { Ok(id) })
    }
    /// Store a new title. The default does nothing.
    fn rename(&self, _remote_id: &str, _title: &str) -> AdapterFuture<()> {
        Box::pin(async { Ok(()) })
    }
    /// Mark the thread archived. The default does nothing.
    fn archive(&self, _remote_id: &str) -> AdapterFuture<()> {
        Box::pin(async { Ok(()) })
    }
    /// Restore an archived thread. The default does nothing.
    fn unarchive(&self, _remote_id: &str) -> AdapterFuture<()> {
        Box::pin(async { Ok(()) })
    }
    /// Delete the thread. The default does nothing.
    fn delete(&self, _remote_id: &str) -> AdapterFuture<()> {
        Box::pin(async { Ok(()) })
    }
    /// A title for a thread, from its messages.
    fn generate_title(
        &self,
        _remote_id: &str,
        _messages: Vec<Arc<ThreadMessage>>,
    ) -> AdapterFuture<Option<String>> {
        Box::pin(async { Ok(None) })
    }
    /// The stored history of a thread, loaded when it is first opened.
    fn load(&self, _remote_id: &str) -> AdapterFuture<ExportedRepository> {
        Box::pin(async { Ok(ExportedRepository::default()) })
    }
}

type Factory = dyn Fn(&str) -> Rc<dyn ThreadRuntime>;

struct Entry {
    item: ThreadItem,
    runtime: Option<Rc<dyn ThreadRuntime>>,
    /// Keeps the thread's change subscription alive.
    _subscription: Option<Subscription>,
    loaded: bool,
    initializing: bool,
    /// A title was already requested; asked at most once per thread.
    titled: bool,
}

struct State {
    entries: Vec<Entry>,
    main: String,
    error: Option<String>,
    loading: bool,
}

struct Inner {
    me: Weak<Inner>,
    factory: Box<Factory>,
    adapter: Option<Rc<dyn ThreadListAdapter>>,
    spawner: Spawner,
    state: RefCell<State>,
    subscribers: Rc<Subscribers>,
    next_id: Cell<u64>,
}

/// The user's threads, with one main thread shown at a time.
#[derive(Clone)]
pub struct ThreadList {
    inner: Rc<Inner>,
}

impl ThreadList {
    /// A list with one empty new thread as the main thread. With an adapter,
    /// the stored threads are fetched in the background.
    pub fn new(
        factory: impl Fn(&str) -> Rc<dyn ThreadRuntime> + 'static,
        spawner: Spawner,
        adapter: Option<Rc<dyn ThreadListAdapter>>,
    ) -> Self {
        let list = Self {
            inner: Rc::new_cyclic(|me| Inner {
                me: me.clone(),
                factory: Box::new(factory),
                adapter,
                spawner,
                state: RefCell::new(State {
                    entries: Vec::new(),
                    main: String::new(),
                    error: None,
                    loading: false,
                }),
                subscribers: Rc::default(),
                next_id: Cell::new(1),
            }),
        };
        let id = list.inner.add_new_entry();
        list.inner.state.borrow_mut().main = id;
        if list.inner.adapter.is_some() {
            list.refresh();
        }
        list
    }

    /// Called after every change. Drop the guard to unsubscribe.
    pub fn subscribe(&self, callback: impl Fn() + 'static) -> Subscription {
        self.inner.subscribers.subscribe(callback)
    }

    /// Every thread, in list order (new first, then as fetched or created).
    pub fn items(&self) -> Vec<ThreadItem> {
        self.inner
            .state
            .borrow()
            .entries
            .iter()
            .map(|e| e.item.clone())
            .collect()
    }
    /// The threads in the main list.
    pub fn regular(&self) -> Vec<ThreadItem> {
        self.with_status(ThreadStatus::Regular)
    }
    /// The archived threads.
    pub fn archived(&self) -> Vec<ThreadItem> {
        self.with_status(ThreadStatus::Archived)
    }
    fn with_status(&self, status: ThreadStatus) -> Vec<ThreadItem> {
        self.items()
            .into_iter()
            .filter(|i| i.status == status)
            .collect()
    }
    /// One thread, by local id.
    pub fn item(&self, id: &str) -> Option<ThreadItem> {
        self.items().into_iter().find(|i| i.id == id)
    }
    /// The local id of the thread being shown.
    pub fn main_id(&self) -> String {
        self.inner.state.borrow().main.clone()
    }
    /// The main thread's runtime, created on first use.
    pub fn main(&self) -> Rc<dyn ThreadRuntime> {
        let id = self.main_id();
        self.inner.runtime(&id).expect("the main thread exists")
    }
    /// The runtime of any thread, created on first use.
    pub fn runtime(&self, id: &str) -> Option<Rc<dyn ThreadRuntime>> {
        self.inner.runtime(id)
    }
    /// The adapter is fetching the stored threads.
    pub fn is_loading(&self) -> bool {
        self.inner.state.borrow().loading
    }
    /// The last background failure, until the next successful change.
    pub fn last_error(&self) -> Option<String> {
        self.inner.state.borrow().error.clone()
    }

    /// Fetch the stored threads (again) and add the ones not yet listed.
    pub fn refresh(&self) {
        let Some(adapter) = self.inner.adapter.clone() else {
            return;
        };
        self.inner.state.borrow_mut().loading = true;
        self.inner.notify();
        let weak = self.inner.me.clone();
        self.inner.spawn(async move {
            let result = adapter.list().await;
            let Some(inner) = weak.upgrade() else { return };
            {
                let mut state = inner.state.borrow_mut();
                state.loading = false;
                match result {
                    Ok(threads) => {
                        let mut added = Vec::new();
                        for thread in threads {
                            if state
                                .entries
                                .iter()
                                .any(|e| e.item.remote_id.as_deref() == Some(&thread.remote_id))
                            {
                                continue;
                            }
                            added.push(thread);
                        }
                        for thread in added {
                            let id = format!("thread-{}", inner.next_id.get());
                            inner.next_id.set(inner.next_id.get() + 1);
                            state.entries.push(Entry {
                                item: ThreadItem {
                                    id,
                                    remote_id: Some(thread.remote_id),
                                    status: if thread.archived {
                                        ThreadStatus::Archived
                                    } else {
                                        ThreadStatus::Regular
                                    },
                                    title: thread.title,
                                },
                                runtime: None,
                                _subscription: None,
                                loaded: false,
                                initializing: false,
                                titled: false,
                            });
                        }
                    }
                    Err(error) => state.error = Some(error),
                }
            }
            inner.notify();
        });
    }

    /// Start a fresh thread, or return to the empty one already there.
    pub fn switch_to_new(&self) {
        let existing = {
            let state = self.inner.state.borrow();
            state
                .entries
                .iter()
                .find(|e| e.item.status == ThreadStatus::New)
                .map(|e| e.item.id.clone())
        };
        let id = match existing {
            Some(id) => id,
            None => self.inner.add_new_entry(),
        };
        self.switch_to(&id);
    }

    /// Make a thread the main thread; a stored thread loads its history.
    pub fn switch_to(&self, id: &str) {
        let known = self
            .inner
            .state
            .borrow()
            .entries
            .iter()
            .any(|e| e.item.id == id);
        if !known {
            return;
        }
        self.inner.state.borrow_mut().main = id.to_owned();
        self.inner.load_history(id);
        self.inner.notify();
    }

    /// Rename a thread. The change shows at once and is undone if the adapter fails.
    pub fn rename(&self, id: &str, title: &str) {
        let title = title.trim().to_owned();
        let (previous, remote) = {
            let mut state = self.inner.state.borrow_mut();
            let Some(entry) = state.entries.iter_mut().find(|e| e.item.id == id) else {
                return;
            };
            let previous = std::mem::replace(
                &mut entry.item.title,
                (!title.is_empty()).then(|| title.clone()),
            );
            (previous, entry.item.remote_id.clone())
        };
        self.inner.notify();
        if let (Some(adapter), Some(remote)) = (self.inner.adapter.clone(), remote) {
            let id = id.to_owned();
            self.inner
                .persist(adapter.rename(&remote, &title), move |inner| {
                    inner.with_entry(&id, |e| e.item.title = previous);
                });
        }
    }

    /// Archive a thread. The change shows at once and is undone if the adapter fails.
    pub fn archive(&self, id: &str) {
        self.set_archived(id, true);
    }
    /// Unarchive a thread. The change shows at once and is undone if the adapter fails.
    pub fn unarchive(&self, id: &str) {
        self.set_archived(id, false);
    }
    fn set_archived(&self, id: &str, archived: bool) {
        let (previous, remote) = {
            let mut state = self.inner.state.borrow_mut();
            let Some(entry) = state.entries.iter_mut().find(|e| e.item.id == id) else {
                return;
            };
            let wanted = if archived {
                ThreadStatus::Archived
            } else {
                ThreadStatus::Regular
            };
            if entry.item.status != ThreadStatus::Regular
                && entry.item.status != ThreadStatus::Archived
            {
                return;
            }
            let previous = std::mem::replace(&mut entry.item.status, wanted);
            (previous, entry.item.remote_id.clone())
        };
        // Archiving the open thread moves on to a new one.
        if archived && self.main_id() == id {
            self.switch_to_new();
        }
        self.inner.notify();
        if let (Some(adapter), Some(remote)) = (self.inner.adapter.clone(), remote) {
            let id = id.to_owned();
            let future = if archived {
                adapter.archive(&remote)
            } else {
                adapter.unarchive(&remote)
            };
            self.inner.persist(future, move |inner| {
                inner.with_entry(&id, |e| e.item.status = previous);
            });
        }
    }

    /// Delete a thread. The change shows at once and is undone if the adapter fails.
    pub fn delete(&self, id: &str) {
        let removed = {
            let mut state = self.inner.state.borrow_mut();
            let Some(index) = state.entries.iter().position(|e| e.item.id == id) else {
                return;
            };
            (index, state.entries.remove(index))
        };
        if self.main_id() == id {
            self.switch_to_new();
        } else {
            self.inner.notify();
        }
        if let (Some(adapter), Some(remote)) =
            (self.inner.adapter.clone(), removed.1.item.remote_id.clone())
        {
            let (index, mut entry) = removed;
            // A failed delete puts the thread back (without its live runtime).
            entry.runtime = None;
            entry._subscription = None;
            entry.loaded = false;
            self.inner.persist(adapter.delete(&remote), move |inner| {
                let mut state = inner.state.borrow_mut();
                let index = index.min(state.entries.len());
                state.entries.insert(index, entry);
            });
        }
    }
}

impl Inner {
    fn notify(&self) {
        self.subscribers.notify();
    }

    fn spawn(&self, future: impl Future<Output = ()> + 'static) {
        (self.spawner)(Box::pin(future));
    }

    fn with_entry(&self, id: &str, change: impl FnOnce(&mut Entry)) {
        if let Some(entry) = self
            .state
            .borrow_mut()
            .entries
            .iter_mut()
            .find(|e| e.item.id == id)
        {
            change(entry);
        }
    }

    fn add_new_entry(&self) -> String {
        let id = format!("thread-{}", self.next_id.get());
        self.next_id.set(self.next_id.get() + 1);
        self.state.borrow_mut().entries.insert(
            0,
            Entry {
                item: ThreadItem {
                    id: id.clone(),
                    remote_id: None,
                    status: ThreadStatus::New,
                    title: None,
                },
                runtime: None,
                _subscription: None,
                loaded: true,
                initializing: false,
                titled: false,
            },
        );
        id
    }

    /// Run an adapter task in the background; `undo` reverts the change if it fails.
    fn persist(&self, task: AdapterFuture<()>, undo: impl FnOnce(&Inner) + 'static) {
        let weak = self.me.clone();
        self.spawn(async move {
            let result = task.await;
            let Some(inner) = weak.upgrade() else { return };
            match result {
                Ok(()) => inner.state.borrow_mut().error = None,
                Err(error) => {
                    undo(&inner);
                    inner.state.borrow_mut().error = Some(error);
                }
            }
            inner.notify();
        });
    }

    /// The thread's runtime, created (and observed) on first use.
    fn runtime(&self, id: &str) -> Option<Rc<dyn ThreadRuntime>> {
        if let Some(runtime) = self
            .state
            .borrow()
            .entries
            .iter()
            .find(|e| e.item.id == id)
            .map(|e| e.runtime.clone())?
        {
            return Some(runtime);
        }
        let runtime = (self.factory)(id);
        let weak = self.me.clone();
        let thread = id.to_owned();
        let subscription = runtime.subscribe_dyn(Box::new(move || {
            if let Some(inner) = weak.upgrade() {
                inner.thread_changed(&thread);
            }
        }));
        let mut state = self.state.borrow_mut();
        let entry = state.entries.iter_mut().find(|e| e.item.id == id)?;
        entry.runtime = Some(runtime.clone());
        entry._subscription = Some(subscription);
        Some(runtime)
    }

    /// Load a stored thread's history the first time it is opened.
    fn load_history(&self, id: &str) {
        let Some(adapter) = self.adapter.clone() else {
            let _ = self.runtime(id);
            return;
        };
        let remote = {
            let mut state = self.state.borrow_mut();
            let Some(entry) = state.entries.iter_mut().find(|e| e.item.id == id) else {
                return;
            };
            match (&entry.item.remote_id, entry.loaded) {
                (Some(remote), false) => {
                    entry.loaded = true;
                    Some(remote.clone())
                }
                _ => None,
            }
        };
        let Some(runtime) = self.runtime(id) else {
            return;
        };
        let Some(remote) = remote else { return };
        let (weak, id) = (self.me.clone(), id.to_owned());
        self.spawn(async move {
            let result = adapter.load(&remote).await;
            let Some(inner) = weak.upgrade() else { return };
            match result {
                Ok(history) => {
                    // A thread the user already started is not overwritten.
                    if runtime.messages().is_empty() && runtime.import(history).is_err() {
                        inner.state.borrow_mut().error = Some("could not load the thread".into());
                    }
                }
                Err(error) => {
                    inner.with_entry(&id, |e| e.loaded = false);
                    inner.state.borrow_mut().error = Some(error);
                }
            }
            inner.notify();
        });
    }

    /// A thread's runtime changed: register it on its first message and ask
    /// for a title after its first answer.
    fn thread_changed(&self, id: &str) {
        let Some(runtime) = self
            .state
            .borrow()
            .entries
            .iter()
            .find(|e| e.item.id == id)
            .and_then(|e| e.runtime.clone())
        else {
            return;
        };
        let messages = runtime.messages();
        let running = runtime.is_running();
        let (register, title_for) = {
            let mut state = self.state.borrow_mut();
            let Some(entry) = state.entries.iter_mut().find(|e| e.item.id == id) else {
                return;
            };
            let mut register = false;
            if entry.item.status == ThreadStatus::New && !messages.is_empty() {
                entry.item.status = ThreadStatus::Regular;
                register = !entry.initializing && entry.item.remote_id.is_none();
                entry.initializing |= register;
            }
            let answered = !running
                && messages
                    .iter()
                    .any(|m| m.as_assistant().is_some_and(|a| a.status.is_terminal()));
            let title_for =
                (answered && entry.item.title.is_none() && !entry.titled && !entry.initializing)
                    .then(|| entry.item.remote_id.clone())
                    .flatten();
            if title_for.is_some() {
                entry.titled = true;
            }
            (register, title_for)
        };
        if register {
            self.initialize(id);
        }
        if let Some(remote) = title_for {
            self.generate_title(id, &remote, messages.iter().cloned().collect());
        }
        self.notify();
    }

    fn initialize(&self, id: &str) {
        let Some(adapter) = self.adapter.clone() else {
            self.with_entry(id, |e| e.initializing = false);
            return;
        };
        let (weak, id) = (self.me.clone(), id.to_owned());
        self.spawn(async move {
            let result = adapter.initialize(&id).await;
            let Some(inner) = weak.upgrade() else { return };
            match result {
                Ok(remote) => inner.with_entry(&id, |e| {
                    e.item.remote_id = Some(remote);
                    e.initializing = false;
                }),
                Err(error) => {
                    inner.with_entry(&id, |e| e.initializing = false);
                    inner.state.borrow_mut().error = Some(error);
                }
            }
            // The thread may already have been answered while registering.
            inner.thread_changed(&id);
            inner.notify();
        });
    }

    fn generate_title(&self, id: &str, remote: &str, messages: Vec<Arc<ThreadMessage>>) {
        let Some(adapter) = self.adapter.clone() else {
            return;
        };
        let (weak, id, remote) = (self.me.clone(), id.to_owned(), remote.to_owned());
        self.spawn(async move {
            let result = adapter.generate_title(&remote, messages).await;
            let Some(inner) = weak.upgrade() else { return };
            match result {
                Ok(Some(title)) => {
                    inner.with_entry(&id, |e| {
                        if e.item.title.is_none() {
                            e.item.title = Some(title.clone());
                        }
                    });
                    let persist = adapter.rename(&remote, &title);
                    inner.persist(persist, |_| {});
                }
                Ok(None) => {}
                Err(error) => inner.state.borrow_mut().error = Some(error),
            }
            inner.notify();
        });
    }
}