Skip to main content

ravenrs_widgets/
model_picker.rs

1//! Choosing a local AI model: download it, try it, use it or remove it.
2
3use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_app_i18n::{Language, Texts};
7use ravenrs_layout::Align;
8use ravenrs_style::{Semantic, TextStyle};
9
10use crate::context::{BuildCx, NodeId};
11use crate::view::{AnyView, View};
12use crate::{
13    Alert, Button, ButtonSize, ButtonStyle, Card, HStack, IconName, ProgressBar, Select, Text, Tone, VStack,
14};
15
16/// Where a [`ModelChoice`] stands.
17#[derive(Clone, Copy, Debug, PartialEq)]
18#[non_exhaustive]
19#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
20pub enum ModelStatus {
21    /// It can be downloaded.
22    Available,
23    /// It is arriving, from 0 to 1.
24    Downloading(f32),
25    /// It is on this machine.
26    Installed,
27    /// It is being tried.
28    Testing,
29}
30
31/// A model offered by a [`ModelPicker`].
32#[derive(Clone, Debug, PartialEq)]
33#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
34pub struct ModelChoice {
35    /// Its name.
36    pub name: String,
37    /// What to know before downloading it, such as its size and license.
38    pub details: String,
39    /// Where it stands.
40    pub status: ModelStatus,
41    /// Whether the app is using it.
42    pub in_use: bool,
43    /// How it did when tried, in words.
44    pub result: Option<String>,
45    /// What went wrong the last time, in words.
46    pub error: Option<String>,
47}
48
49/// The words a [`ModelPicker`] shows. English by default.
50#[derive(Clone, Debug)]
51#[non_exhaustive]
52#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
53pub struct ModelPickerLabels {
54    /// Downloads a model.
55    pub download: String,
56    /// Stops a download.
57    pub cancel: String,
58    /// Tries a model.
59    pub test: String,
60    /// While a model is being tried.
61    pub testing: String,
62    /// Starts using a model.
63    pub use_it: String,
64    /// Stops using a model.
65    pub stop_using: String,
66    /// Marks the model in use.
67    pub in_use: String,
68    /// Removes a model from the machine.
69    pub remove: String,
70    /// When no model fits this machine.
71    pub nothing_fits: String,
72    /// The list of models.
73    pub model: String,
74    /// Marks a model that is on this machine.
75    pub downloaded: String,
76}
77
78impl Default for ModelPickerLabels {
79    fn default() -> Self {
80        Self::from_texts(&Texts::new(Language::English))
81    }
82}
83
84#[cfg_attr(feature = "live", ravenrs_live::expose)]
85impl ModelPickerLabels {
86    /// The texts of a catalog, in its language. What it does not have comes from English.
87    pub fn from_texts(texts: &Texts) -> Self {
88        Self {
89            download: texts.get("widgets.model_picker.download"),
90            cancel: texts.get("widgets.model_picker.cancel"),
91            test: texts.get("widgets.model_picker.test"),
92            testing: texts.get("widgets.model_picker.testing"),
93            use_it: texts.get("widgets.model_picker.use_it"),
94            stop_using: texts.get("widgets.model_picker.stop_using"),
95            in_use: texts.get("widgets.model_picker.in_use"),
96            remove: texts.get("widgets.model_picker.remove"),
97            nothing_fits: texts.get("widgets.model_picker.nothing_fits"),
98            model: texts.get("widgets.model_picker.model"),
99            downloaded: texts.get("widgets.model_picker.downloaded"),
100        }
101    }
102
103    /// The words in Brazilian Portuguese.
104    pub fn pt_br() -> Self {
105        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
106    }
107}
108
109type OnChoice = Rc<dyn Fn(usize)>;
110
111/// A choice of local AI models, however many there are, to download, try, use and remove. It shows what it is
112/// given: deciding which models fit and doing the work is up to the app, or to
113/// `ravenrs-ai`.
114///
115/// ```
116/// use ravenrs_widgets::{ModelChoice, ModelPicker, ModelStatus};
117///
118/// let picker = ModelPicker::new(vec![ModelChoice {
119///     name: "Small model".into(),
120///     details: "1.1 GB · Apache 2.0".into(),
121///     status: ModelStatus::Available,
122///     in_use: false,
123///     result: None,
124///     error: None,
125/// }])
126/// .on_download(|index| println!("download {index}"));
127/// ```
128#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
129pub struct ModelPicker {
130    choices: Vec<ModelChoice>,
131    labels: ModelPickerLabels,
132    on_download: Option<OnChoice>,
133    on_cancel: Option<OnChoice>,
134    on_test: Option<OnChoice>,
135    on_use: Option<OnChoice>,
136    on_remove: Option<OnChoice>,
137}
138
139impl fmt::Debug for ModelPicker {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        f.debug_struct("ModelPicker").field("choices", &self.choices).finish_non_exhaustive()
142    }
143}
144
145#[cfg_attr(feature = "live", ravenrs_live::expose)]
146impl ModelPicker {
147    /// A list of these models.
148    pub fn new(choices: Vec<ModelChoice>) -> Self {
149        Self {
150            choices,
151            labels: ModelPickerLabels::default(),
152            on_download: None,
153            on_cancel: None,
154            on_test: None,
155            on_use: None,
156            on_remove: None,
157        }
158    }
159
160    /// The words it shows.
161    #[must_use]
162    pub fn labels(mut self, labels: ModelPickerLabels) -> Self {
163        self.labels = labels;
164        self
165    }
166
167    /// Called with a model's position when someone asks to download it.
168    #[must_use]
169    pub fn on_download(mut self, handler: impl Fn(usize) + 'static) -> Self {
170        self.on_download = Some(Rc::new(handler));
171        self
172    }
173
174    /// Called with a model's position when someone stops its download.
175    #[must_use]
176    pub fn on_cancel(mut self, handler: impl Fn(usize) + 'static) -> Self {
177        self.on_cancel = Some(Rc::new(handler));
178        self
179    }
180
181    /// Called with a model's position when someone asks to try it.
182    #[must_use]
183    pub fn on_test(mut self, handler: impl Fn(usize) + 'static) -> Self {
184        self.on_test = Some(Rc::new(handler));
185        self
186    }
187
188    /// Called with a model's position when someone starts or stops using it.
189    #[must_use]
190    pub fn on_use(mut self, handler: impl Fn(usize) + 'static) -> Self {
191        self.on_use = Some(Rc::new(handler));
192        self
193    }
194
195    /// Called with a model's position when someone removes it.
196    #[must_use]
197    pub fn on_remove(mut self, handler: impl Fn(usize) + 'static) -> Self {
198        self.on_remove = Some(Rc::new(handler));
199        self
200    }
201
202    fn button(
203        label: &str,
204        style: ButtonStyle,
205        icon: IconName,
206        index: usize,
207        handler: Option<&OnChoice>,
208    ) -> Button {
209        let handler = handler.cloned();
210        Button::new(label.to_owned()).style(style).size(ButtonSize::Small).icon(icon).on_click(move || {
211            if let Some(handler) = &handler {
212                handler(index);
213            }
214        })
215    }
216
217    fn actions(&self, index: usize, choice: &ModelChoice) -> AnyView {
218        let labels = &self.labels;
219        match choice.status {
220            ModelStatus::Available => Box::new(Self::button(
221                &labels.download,
222                ButtonStyle::Primary,
223                IconName::Download,
224                index,
225                self.on_download.as_ref(),
226            )),
227            ModelStatus::Downloading(_) => Box::new(Self::button(
228                &labels.cancel,
229                ButtonStyle::Secondary,
230                IconName::X,
231                index,
232                self.on_cancel.as_ref(),
233            )),
234            ModelStatus::Testing => Box::new(
235                Text::new(labels.testing.clone()).style(TextStyle::Callout).foreground(Semantic::InkMuted),
236            ),
237            _ => Box::new(
238                HStack::new((
239                    self.on_test.as_ref().map(|handler| {
240                        Self::button(
241                            &labels.test,
242                            ButtonStyle::Secondary,
243                            IconName::Zap,
244                            index,
245                            Some(handler),
246                        )
247                    }),
248                    self.on_use.as_ref().map(|handler| {
249                        Self::button(
250                            if choice.in_use { &labels.stop_using } else { &labels.use_it },
251                            if choice.in_use { ButtonStyle::Secondary } else { ButtonStyle::Primary },
252                            IconName::Sparkles,
253                            index,
254                            Some(handler),
255                        )
256                    }),
257                    Self::button(
258                        &labels.remove,
259                        ButtonStyle::Ghost,
260                        IconName::Trash2,
261                        index,
262                        self.on_remove.as_ref(),
263                    ),
264                ))
265                .spacing(8.0)
266                .wrap(),
267            ),
268        }
269    }
270}
271
272#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
273impl ModelPicker {
274    /// How a model reads in the list: its name and where it stands.
275    fn option(&self, choice: &ModelChoice) -> String {
276        let labels = &self.labels;
277        match choice.status {
278            _ if choice.in_use => format!("{} · {}", choice.name, labels.in_use),
279            ModelStatus::Downloading(fraction) => {
280                format!("{} · {:.0}%", choice.name, fraction.clamp(0.0, 1.0) * 100.0)
281            }
282            ModelStatus::Installed | ModelStatus::Testing => {
283                format!("{} · {}", choice.name, labels.downloaded)
284            }
285            _ => choice.name.clone(),
286        }
287    }
288}
289
290impl View for ModelPicker {
291    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
292        if self.choices.is_empty() {
293            let text = Text::new(self.labels.nothing_fits.clone())
294                .style(TextStyle::Callout)
295                .foreground(Semantic::InkMuted);
296            return cx.child(&text);
297        }
298        // It opens on the model that matters: the one in use, or else one already here.
299        let selected = cx.use_state(|| {
300            let installed = |choice: &ModelChoice| choice.status != ModelStatus::Available;
301            self.choices
302                .iter()
303                .position(|choice| choice.in_use)
304                .or_else(|| self.choices.iter().position(installed))
305                .unwrap_or(0)
306        });
307        let index = selected.get().min(self.choices.len() - 1);
308        let choice = &self.choices[index];
309        let options: Vec<String> = self.choices.iter().map(|choice| self.option(choice)).collect();
310        let progress = match choice.status {
311            ModelStatus::Downloading(fraction) => Some(ProgressBar::new(fraction)),
312            _ => None,
313        };
314        let card = Card::new(
315            VStack::new((
316                Select::new(selected.binding(), options).label(self.labels.model.clone()),
317                Text::new(choice.details.clone()).style(TextStyle::Callout).foreground(Semantic::InkMuted),
318                progress,
319                choice.result.clone().map(|result| Text::new(result).style(TextStyle::Callout)),
320                choice.error.clone().map(|error| Alert::new("", error).tone(Tone::Danger)),
321                self.actions(index, choice),
322            ))
323            .spacing(10.0)
324            .align(Align::Stretch),
325        );
326        cx.child(&card)
327    }
328}