Skip to main content

studio_worker/ui/pages/
models.rs

1//! Models: what is in memory, what can be, and one action per model
2//! (the daemon's `/models/:id/{load,unload}`).  Two groups that never
3//! reorder, so a model stays where it is while it loads or unloads.
4
5use chrono::{DateTime, Utc};
6use eframe::egui::{self, vec2, Align, Color32, Layout, RichText};
7
8use crate::daemon_api::ModelEntry;
9
10use super::super::format::format_age;
11use super::super::icons::{self, Icon};
12use super::super::pulse::{format_gb, holds_memory, GpuMemory};
13use super::super::theme::{Palette, Tone};
14use super::super::widgets;
15
16/// What the operator asked for.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ModelAction {
19    Load(String),
20    Unload(String),
21}
22
23/// The one control a model row offers.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum RowControl {
26    Load,
27    /// Load again after a failure.
28    Retry,
29    /// Unload; also while loading (the load completes first).
30    Unload,
31    /// A transition under way: a disabled button saying so.
32    InProgress(&'static str),
33    /// Nothing to do here, and why.
34    Unavailable(&'static str),
35}
36
37impl RowControl {
38    /// The button's label.
39    pub fn label(self) -> &'static str {
40        match self {
41            RowControl::Load => "Load",
42            RowControl::Retry => "Retry",
43            RowControl::Unload => "Unload",
44            RowControl::InProgress(label) | RowControl::Unavailable(label) => label,
45        }
46    }
47
48    /// The action a click sends for the model `id`.
49    pub fn action(self, id: &str) -> Option<ModelAction> {
50        match self {
51            RowControl::Load | RowControl::Retry => Some(ModelAction::Load(id.to_string())),
52            RowControl::Unload => Some(ModelAction::Unload(id.to_string())),
53            RowControl::InProgress(_) | RowControl::Unavailable(_) => None,
54        }
55    }
56}
57
58/// The control the lifecycle allows from `state` (see
59/// `docs/runtime/model-lifecycle.md`).
60pub fn control_for(state: &str, enabled: bool, loadable: bool) -> RowControl {
61    if !enabled {
62        return RowControl::Unavailable("Disabled");
63    }
64    if !loadable {
65        return RowControl::Unavailable("Per job");
66    }
67    match state {
68        "unloaded" => RowControl::Load,
69        "failed" => RowControl::Retry,
70        "loaded" | "loading" => RowControl::Unload,
71        "unloading" => RowControl::InProgress("Unloading\u{2026}"),
72        _ => RowControl::Unavailable("Unknown"),
73    }
74}
75
76/// The tone of a lifecycle state.
77pub fn state_tone(state: &str) -> Tone {
78    match state {
79        "loaded" => Tone::Good,
80        "loading" | "unloading" => Tone::Busy,
81        "failed" => Tone::Bad,
82        _ => Tone::Neutral,
83    }
84}
85
86/// One model as the page shows it.
87#[derive(Debug, Clone, PartialEq)]
88pub struct ModelRow {
89    pub id: String,
90    pub name: String,
91    pub kind: &'static str,
92    pub engine: String,
93    pub vram_gb: f32,
94    pub state: String,
95    pub resident: bool,
96    pub since: Option<DateTime<Utc>>,
97    pub error: Option<String>,
98    pub exclusive_group: Option<String>,
99    pub control: RowControl,
100}
101
102impl ModelRow {
103    pub fn from_entry(entry: &ModelEntry) -> Self {
104        let engine = serde_json::to_value(&entry.source.engine)
105            .ok()
106            .and_then(|v| v.as_str().map(str::to_string))
107            .unwrap_or_default();
108        Self {
109            id: entry.id.clone(),
110            name: entry.display_name.clone(),
111            kind: entry.kind.as_str(),
112            engine,
113            vram_gb: entry.vram_gb_estimate,
114            state: entry.state.clone(),
115            resident: entry.resident,
116            since: entry.since,
117            error: entry.error.clone(),
118            exclusive_group: entry.exclusive_group.clone(),
119            control: control_for(&entry.state, entry.enabled, entry.loadable),
120        }
121    }
122
123    /// `llm · llama-cpp · ≈ 1.5 GB`.
124    pub fn meta_line(&self) -> String {
125        format!(
126            "{} \u{00b7} {} \u{00b7} \u{2248} {} GB",
127            self.kind,
128            self.engine,
129            format_gb(self.vram_gb)
130        )
131    }
132}
133
134/// The page, grouped.
135#[derive(Debug, Clone, PartialEq)]
136pub struct ModelsView {
137    /// Models with an in-process loader: they can stay in memory.
138    pub kept: Vec<ModelRow>,
139    /// Models whose engine loads for each job.
140    pub per_job: Vec<ModelRow>,
141    pub memory: GpuMemory,
142    /// Each model holding memory: its name and estimate, in catalogue order.
143    pub holders: Vec<(String, f32)>,
144}
145
146impl ModelsView {
147    pub fn build(models: &[ModelEntry], vram_total_gb: f32) -> Self {
148        let (kept, per_job) = models
149            .iter()
150            .partition::<Vec<&ModelEntry>, _>(|m| m.loadable);
151        Self {
152            kept: kept.into_iter().map(ModelRow::from_entry).collect(),
153            per_job: per_job.into_iter().map(ModelRow::from_entry).collect(),
154            memory: GpuMemory::from_models(models, vram_total_gb),
155            holders: models
156                .iter()
157                .filter(|m| holds_memory(&m.state))
158                .map(|m| (m.display_name.clone(), m.vram_gb_estimate.max(0.0)))
159                .collect(),
160        }
161    }
162
163    pub fn is_empty(&self) -> bool {
164        self.kept.is_empty() && self.per_job.is_empty()
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Rendering
170// ---------------------------------------------------------------------------
171
172/// Width of the state column and of the action button, in points.
173const STATE_COLUMN: f32 = 118.0;
174const ACTION_WIDTH: f32 = 104.0;
175
176/// Draw the page; answers the action the operator took, if any.
177pub fn render(ui: &mut egui::Ui, view: &ModelsView) -> Option<ModelAction> {
178    widgets::page_title(
179        ui,
180        "Models",
181        "Loaded models stay in memory and answer at once; resident ones come back after a \
182         restart.  Unloading frees their memory.",
183    );
184    memory_card(ui, view);
185    ui.add_space(16.0);
186    if view.is_empty() {
187        widgets::card(ui, |ui| {
188            widgets::empty_state(
189                ui,
190                Icon::Models,
191                "The catalogue is empty",
192                "Models appear here once the daemon knows them.",
193            );
194        });
195        return None;
196    }
197    let now = Utc::now();
198    let mut action = None;
199    for (title, rows) in [
200        ("KEPT IN MEMORY", &view.kept),
201        ("LOADED PER JOB", &view.per_job),
202    ] {
203        if rows.is_empty() {
204            continue;
205        }
206        widgets::section_label(ui, &format!("{title} \u{00b7} {}", rows.len()));
207        for row in rows {
208            if let Some(clicked) = model_row(ui, row, now) {
209                action = Some(clicked);
210            }
211            ui.add_space(8.0);
212        }
213        ui.add_space(10.0);
214    }
215    action
216}
217
218fn segment_colours(p: &Palette) -> [Color32; 4] {
219    [p.info, p.good, p.accent, p.muted]
220}
221
222fn memory_card(ui: &mut egui::Ui, view: &ModelsView) {
223    let p = Palette::of_ui(ui);
224    widgets::card(ui, |ui| {
225        ui.horizontal(|ui| {
226            icons::show(ui, Icon::Models, 22.0, p.muted);
227            ui.label(
228                RichText::new(format!(
229                    "{} GB held by loaded models",
230                    format_gb(view.memory.held_gb)
231                ))
232                .size(17.0)
233                .strong()
234                .color(p.text),
235            );
236            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
237                let total = if view.memory.total_gb > 0.0 {
238                    format!("of {} GB on the device", format_gb(view.memory.total_gb))
239                } else {
240                    "device total unknown".to_string()
241                };
242                ui.label(widgets::muted(ui, total));
243            });
244        });
245        ui.add_space(8.0);
246        let colours = segment_colours(p);
247        let total = view
248            .memory
249            .total_gb
250            .max(view.memory.held_gb)
251            .max(f32::EPSILON);
252        let segments: Vec<(f32, Color32)> = view
253            .holders
254            .iter()
255            .enumerate()
256            .map(|(i, (_, gb))| (gb / total, colours[i % colours.len()]))
257            .collect();
258        widgets::meter(ui, ui.available_width(), 10.0, &segments);
259        ui.add_space(6.0);
260        ui.horizontal_wrapped(|ui| {
261            if view.holders.is_empty() {
262                ui.label(widgets::muted(
263                    ui,
264                    "Nothing loaded: the device is free for per-job engines.",
265                ));
266            }
267            for (i, (name, gb)) in view.holders.iter().enumerate() {
268                let (rect, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
269                ui.painter()
270                    .circle_filled(rect.center(), 4.0, colours[i % colours.len()]);
271                ui.label(
272                    RichText::new(format!("{name} \u{2248} {} GB", format_gb(*gb))).color(p.text),
273                );
274                ui.add_space(10.0);
275            }
276        });
277        ui.label(
278            widgets::muted(
279                ui,
280                "Estimates from the catalogue; per-job engines are not counted.",
281            )
282            .small(),
283        );
284    });
285}
286
287fn model_row(ui: &mut egui::Ui, row: &ModelRow, now: DateTime<Utc>) -> Option<ModelAction> {
288    let p = Palette::of_ui(ui);
289    let mut action = None;
290    widgets::card(ui, |ui| {
291        ui.horizontal_top(|ui| {
292            // State column: a glance down it reads every model's state.
293            ui.allocate_ui_with_layout(
294                vec2(STATE_COLUMN, 44.0),
295                Layout::top_down(Align::Min),
296                |ui| {
297                    ui.set_width(STATE_COLUMN);
298                    ui.horizontal(|ui| {
299                        let tone = state_tone(&row.state);
300                        widgets::status_dot(ui, tone, 0.0);
301                        ui.label(RichText::new(&row.state).strong().color(p.tone(tone)));
302                    });
303                    if let Some(since) = row.since {
304                        ui.label(
305                            widgets::muted(ui, format!("since {}", format_age(now, since))).small(),
306                        );
307                    }
308                },
309            );
310            let middle = (ui.available_width() - ACTION_WIDTH - 12.0).max(120.0);
311            ui.allocate_ui_with_layout(vec2(middle, 44.0), Layout::top_down(Align::Min), |ui| {
312                ui.set_width(middle);
313                ui.horizontal_wrapped(|ui| {
314                    ui.label(RichText::new(&row.name).strong().color(p.text));
315                    ui.label(RichText::new(&row.id).monospace().small().color(p.muted));
316                });
317                ui.horizontal_wrapped(|ui| {
318                    ui.label(widgets::muted(ui, row.meta_line()));
319                    if row.resident {
320                        widgets::pill(ui, "resident", Tone::Info)
321                            .on_hover_text("loaded again when the daemon restarts");
322                    }
323                    if let Some(group) = &row.exclusive_group {
324                        widgets::pill(ui, &format!("one of {group}"), Tone::Neutral)
325                            .on_hover_text("only one model of this group is loaded at a time");
326                    }
327                });
328            });
329            ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
330                // Quiet buttons: the state column carries the colour.
331                let clicked = match row.control {
332                    RowControl::Load | RowControl::Retry | RowControl::Unload => {
333                        widgets::button(ui, row.control.label(), true, ACTION_WIDTH).clicked()
334                    }
335                    RowControl::InProgress(label) | RowControl::Unavailable(label) => {
336                        let hint = if row.control == RowControl::Unavailable("Per job") {
337                            "no in-process loader: this engine loads for each job"
338                        } else {
339                            "nothing to do right now"
340                        };
341                        widgets::button(ui, label, false, ACTION_WIDTH)
342                            .on_disabled_hover_text(hint);
343                        false
344                    }
345                };
346                if clicked {
347                    action = row.control.action(&row.id);
348                }
349            });
350        });
351        if let Some(error) = &row.error {
352            ui.add_space(6.0);
353            widgets::problem_box(ui, error);
354        }
355    });
356    action
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::daemon_api::ModelSourceBrief;
363    use crate::types::{ModelEngine, TaskKind};
364
365    fn entry(id: &str, state: &str, loadable: bool) -> ModelEntry {
366        ModelEntry {
367            id: id.into(),
368            display_name: format!("Model {id}"),
369            kind: TaskKind::Llm,
370            vram_gb_estimate: 1.5,
371            source: ModelSourceBrief {
372                engine: ModelEngine::LlamaCpp,
373            },
374            enabled: true,
375            exclusive_group: Some("stt".into()),
376            state: state.into(),
377            resident: state == "loaded",
378            since: Some(Utc::now()),
379            error: (state == "failed").then(|| "out of memory".to_string()),
380            loadable,
381        }
382    }
383
384    #[test]
385    fn the_control_follows_the_lifecycle() {
386        assert_eq!(control_for("unloaded", true, true), RowControl::Load);
387        assert_eq!(control_for("failed", true, true), RowControl::Retry);
388        assert_eq!(control_for("loading", true, true), RowControl::Unload);
389        assert_eq!(control_for("loaded", true, true), RowControl::Unload);
390        assert_eq!(
391            control_for("unloading", true, true),
392            RowControl::InProgress("Unloading\u{2026}")
393        );
394        assert_eq!(
395            control_for("unloaded", true, false),
396            RowControl::Unavailable("Per job")
397        );
398        assert_eq!(
399            control_for("unloaded", false, true),
400            RowControl::Unavailable("Disabled")
401        );
402        assert_eq!(
403            control_for("weird", true, true),
404            RowControl::Unavailable("Unknown")
405        );
406    }
407
408    #[test]
409    fn a_control_sends_its_action() {
410        assert_eq!(RowControl::Retry.label(), "Retry");
411        assert_eq!(
412            RowControl::Retry.action("m"),
413            Some(ModelAction::Load("m".into()))
414        );
415        assert_eq!(
416            RowControl::Unload.action("m"),
417            Some(ModelAction::Unload("m".into()))
418        );
419        assert_eq!(RowControl::InProgress("x").action("m"), None);
420        assert_eq!(RowControl::Unavailable("Per job").label(), "Per job");
421    }
422
423    #[test]
424    fn states_have_tones() {
425        assert_eq!(state_tone("loaded"), Tone::Good);
426        assert_eq!(state_tone("loading"), Tone::Busy);
427        assert_eq!(state_tone("unloading"), Tone::Busy);
428        assert_eq!(state_tone("failed"), Tone::Bad);
429        assert_eq!(state_tone("unloaded"), Tone::Neutral);
430    }
431
432    #[test]
433    fn a_row_carries_what_the_operator_needs() {
434        let row = ModelRow::from_entry(&entry("q", "failed", true));
435        assert_eq!(
436            row.meta_line(),
437            "llm \u{00b7} llama-cpp \u{00b7} \u{2248} 1.5 GB"
438        );
439        assert_eq!(row.error.as_deref(), Some("out of memory"));
440        assert_eq!(row.control, RowControl::Retry);
441    }
442
443    #[test]
444    fn models_group_by_loader_in_catalogue_order_and_memory_adds_up() {
445        let models = [
446            entry("a", "loaded", true),
447            entry("sd", "unloaded", false),
448            entry("b", "unloaded", true),
449            entry("c", "loading", true),
450        ];
451        let view = ModelsView::build(&models, 24.0);
452        let ids = |rows: &[ModelRow]| rows.iter().map(|r| r.id.clone()).collect::<Vec<_>>();
453        assert_eq!(ids(&view.kept), ["a", "b", "c"]);
454        assert_eq!(ids(&view.per_job), ["sd"]);
455        assert_eq!(view.memory.held_gb, 3.0);
456        assert_eq!(
457            view.holders,
458            [("Model a".to_string(), 1.5), ("Model c".to_string(), 1.5)]
459        );
460        assert!(!view.is_empty());
461        assert!(ModelsView::build(&[], 0.0).is_empty());
462    }
463
464    #[test]
465    fn every_state_draws_in_both_themes() {
466        let models: Vec<ModelEntry> = ["unloaded", "loading", "loaded", "unloading", "failed"]
467            .into_iter()
468            .enumerate()
469            .map(|(i, s)| entry(&format!("m{i}"), s, i % 2 == 0))
470            .collect();
471        for dark in [true, false] {
472            egui::__run_test_ui(|ui| {
473                ui.ctx()
474                    .set_visuals(super::super::super::theme::visuals(Palette::of(dark)));
475                assert_eq!(render(ui, &ModelsView::build(&models, 24.0)), None);
476                assert_eq!(render(ui, &ModelsView::build(&[], 0.0)), None);
477            });
478        }
479    }
480}