1use chrono::{DateTime, Utc};
5use eframe::egui;
6
7use crate::daemon_api::ModelEntry;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum ModelAction {
12 Load(String),
13 Unload(String),
14}
15
16#[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 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
62pub 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
81pub 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
108fn 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}