Skip to main content

studio_worker/ui/tabs/
models.rs

1//! Models tab: every catalogue model with its lifecycle state, residency,
2//! and Load / Unload controls (the daemon's `/models/:id/{load,unload}`).
3
4use chrono::{DateTime, Utc};
5use eframe::egui;
6
7use crate::daemon_api::ModelEntry;
8
9/// What the operator asked for on the Models tab.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum ModelAction {
12    Load(String),
13    Unload(String),
14}
15
16/// One row of the tab.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ModelRow {
19    pub id: String,
20    pub name: String,
21    pub kind: &'static str,
22    pub engine: String,
23    pub vram_gb: f32,
24    pub state: String,
25    pub resident: bool,
26    pub since: Option<DateTime<Utc>>,
27    pub error: Option<String>,
28    pub enabled: bool,
29    /// The daemon can keep this model loaded.
30    pub loadable: bool,
31    pub exclusive_group: Option<String>,
32    pub can_load: bool,
33    pub can_unload: bool,
34}
35
36impl ModelRow {
37    pub fn from_entry(entry: &ModelEntry) -> Self {
38        let (can_load, can_unload) = controls_for(&entry.state, entry.enabled && entry.loadable);
39        let engine = serde_json::to_value(&entry.source.engine)
40            .ok()
41            .and_then(|v| v.as_str().map(str::to_string))
42            .unwrap_or_default();
43        Self {
44            id: entry.id.clone(),
45            name: entry.display_name.clone(),
46            kind: entry.kind.as_str(),
47            engine,
48            vram_gb: entry.vram_gb_estimate,
49            state: entry.state.clone(),
50            resident: entry.resident,
51            since: entry.since,
52            error: entry.error.clone(),
53            enabled: entry.enabled,
54            loadable: entry.loadable,
55            exclusive_group: entry.exclusive_group.clone(),
56            can_load,
57            can_unload,
58        }
59    }
60}
61
62/// Which of Load / Unload the lifecycle allows from `state`
63/// (see `docs/runtime/model-lifecycle.md`): load from unloaded or failed;
64/// unload from loaded or loading (the load completes first).  A disabled
65/// model, or one without an in-process loader, cannot be loaded.
66pub fn controls_for(state: &str, loadable: bool) -> (bool, bool) {
67    let can_load = loadable && matches!(state, "unloaded" | "failed");
68    let can_unload = matches!(state, "loaded" | "loading");
69    (can_load, can_unload)
70}
71
72fn state_colour(state: &str) -> egui::Color32 {
73    match state {
74        "loaded" => egui::Color32::LIGHT_GREEN,
75        "loading" | "unloading" => egui::Color32::from_rgb(232, 168, 56),
76        "failed" => egui::Color32::LIGHT_RED,
77        _ => egui::Color32::from_gray(170),
78    }
79}
80
81/// Draw the tab; answers the action the operator took, if any.
82pub fn render(ui: &mut egui::Ui, rows: &[ModelRow]) -> Option<ModelAction> {
83    ui.heading(format!("Models ({})", rows.len()));
84    ui.label(
85        egui::RichText::new(
86            "Loaded models stay in memory and answer at once; residency brings them back \
87             after a restart.  Unloading frees their memory.",
88        )
89        .italics()
90        .color(egui::Color32::from_gray(170)),
91    );
92    ui.add_space(8.0);
93    if rows.is_empty() {
94        ui.label(egui::RichText::new("The catalogue is empty.").italics());
95        return None;
96    }
97    let mut action = None;
98    let now = Utc::now();
99    for row in rows {
100        if let Some(clicked) = render_row(ui, row, now) {
101            action = Some(clicked);
102        }
103        ui.add_space(4.0);
104    }
105    action
106}
107
108/// One model as a card: identity and controls on top, lifecycle below.
109fn render_row(ui: &mut egui::Ui, row: &ModelRow, now: DateTime<Utc>) -> Option<ModelAction> {
110    let mut action = None;
111    egui::Frame::group(ui.style()).show(ui, |ui| {
112        ui.set_width(ui.available_width());
113        ui.horizontal(|ui| {
114            ui.label(egui::RichText::new(&row.name).strong());
115            ui.label(
116                egui::RichText::new(&row.id)
117                    .small()
118                    .color(egui::Color32::from_gray(150)),
119            );
120            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
121                if ui
122                    .add_enabled(row.can_unload, egui::Button::new("Unload"))
123                    .clicked()
124                {
125                    action = Some(ModelAction::Unload(row.id.clone()));
126                }
127                if ui
128                    .add_enabled(row.can_load, egui::Button::new("Load"))
129                    .clicked()
130                {
131                    action = Some(ModelAction::Load(row.id.clone()));
132                }
133            });
134        });
135        ui.horizontal(|ui| {
136            ui.label(
137                egui::RichText::new(&row.state)
138                    .color(state_colour(&row.state))
139                    .strong(),
140            );
141            if row.resident {
142                ui.label(
143                    egui::RichText::new("resident").color(egui::Color32::from_rgb(140, 180, 230)),
144                )
145                .on_hover_text("loaded again when the daemon restarts");
146            }
147            if !row.enabled {
148                ui.label("disabled");
149            } else if !row.loadable {
150                ui.label(egui::RichText::new("runs per job").color(egui::Color32::from_gray(150)))
151                    .on_hover_text("no in-process loader for this engine: it loads for each job");
152            }
153            let since = row
154                .since
155                .map(|since| format!("since {}", super::status::format_age(now, since)))
156                .unwrap_or_default();
157            let group = row
158                .exclusive_group
159                .as_ref()
160                .map(|g| format!(" \u{00b7} one of group {g}"))
161                .unwrap_or_default();
162            ui.label(
163                egui::RichText::new(format!(
164                    "{} \u{00b7} {} \u{00b7} {:.1} GB \u{00b7} {since}{group}",
165                    row.kind, row.engine, row.vram_gb
166                ))
167                .color(egui::Color32::from_gray(170)),
168            );
169        });
170        if let Some(error) = &row.error {
171            ui.add(
172                egui::Label::new(
173                    egui::RichText::new(error).color(egui::Color32::from_rgb(230, 140, 130)),
174                )
175                .wrap(),
176            );
177        }
178    });
179    action
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::daemon_api::ModelSourceBrief;
186    use crate::types::{ModelEngine, TaskKind};
187
188    fn entry(state: &str, enabled: bool) -> ModelEntry {
189        ModelEntry {
190            id: "qwen3.5-0.8b".into(),
191            display_name: "Qwen3.5 0.8B".into(),
192            kind: TaskKind::Llm,
193            vram_gb_estimate: 1.5,
194            source: ModelSourceBrief {
195                engine: ModelEngine::LlamaCpp,
196            },
197            enabled,
198            exclusive_group: None,
199            state: state.into(),
200            resident: state == "loaded",
201            since: Some(Utc::now()),
202            error: (state == "failed").then(|| "out of memory".to_string()),
203            loadable: true,
204        }
205    }
206
207    #[test]
208    fn controls_follow_the_lifecycle_guards() {
209        assert_eq!(controls_for("unloaded", true), (true, false));
210        assert_eq!(controls_for("failed", true), (true, false));
211        assert_eq!(controls_for("loading", true), (false, true));
212        assert_eq!(controls_for("loaded", true), (false, true));
213        assert_eq!(controls_for("unloading", true), (false, false));
214        assert_eq!(controls_for("unloaded", false), (false, false));
215    }
216
217    #[test]
218    fn a_row_carries_what_the_operator_needs() {
219        let row = ModelRow::from_entry(&entry("failed", true));
220        assert_eq!(row.kind, "llm");
221        assert_eq!(row.engine, "llama-cpp");
222        assert_eq!(row.error.as_deref(), Some("out of memory"));
223        assert!(row.can_load && !row.can_unload);
224    }
225
226    #[test]
227    fn a_model_without_a_loader_offers_no_load() {
228        let mut model = entry("unloaded", true);
229        model.loadable = false;
230        let row = ModelRow::from_entry(&model);
231        assert!(!row.can_load && !row.can_unload);
232    }
233
234    #[test]
235    fn render_draws_every_state_without_panicking() {
236        let rows: Vec<ModelRow> = ["unloaded", "loading", "loaded", "unloading", "failed"]
237            .into_iter()
238            .map(|s| ModelRow::from_entry(&entry(s, true)))
239            .collect();
240        egui::__run_test_ui(|ui| {
241            assert_eq!(render(ui, &rows), None);
242            assert_eq!(render(ui, &[]), None);
243        });
244    }
245}