1use 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#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ModelAction {
19 Load(String),
20 Unload(String),
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum RowControl {
26 Load,
27 Retry,
29 Unload,
31 InProgress(&'static str),
33 Unavailable(&'static str),
35}
36
37impl RowControl {
38 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 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
58pub 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
76pub 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#[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 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#[derive(Debug, Clone, PartialEq)]
136pub struct ModelsView {
137 pub kept: Vec<ModelRow>,
139 pub per_job: Vec<ModelRow>,
141 pub memory: GpuMemory,
142 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
168const STATE_COLUMN: f32 = 118.0;
174const ACTION_WIDTH: f32 = 104.0;
175
176pub 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 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 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}