T25e Generative: models

ModelCard sets a model's maker, what it does, its facts and tags, and
Use unless it is in use. ModelDownloadManager lists downloads with how
much of how much, speed and time left, and Pause, Resume, Cancel,
Retry and Delete by state. ModelStatus marks a model idle, loading,
loaded with its GPU memory, or failed. HardwareMonitor sets a meter per
part beside its history. FineTuneJobCard shows a job's phase, its
epochs and its loss on training and held-out data.

AGENTS.md: after each item, cargo clean -p ely-gpui-component too;
old builds of the crate had filled the disk. The reveal rule names
layout::bring_into_view.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 13:45:17 -04:00
1 parent 5ba76ace99
commit ff4e828795
12 files changed
+1291 -7

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
use std::rc::Rc;
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
forms::OnValue,
motion::ProgressBar,
primitives::IconName,
theme::{ActiveTheme, ControlSize, TextSize},
typography::{
Ellipsis,
format::{DurationStyle, duration, file_size, percent},
},
};
/// Where a model's download stands.
#[derive(Clone, Debug, PartialEq)]
pub enum DownloadState {
Queued,
/// Under way: the share fetched, and bytes a second.
Fetching(f32, u64),
Paused(f32),
Done,
Failed(SharedString),
}
/// One model to fetch: its key, its name, its size in bytes, and where it stands.
#[derive(Clone, Debug, PartialEq)]
pub struct Download {
pub key: SharedString,
pub name: SharedString,
pub bytes: u64,
pub state: DownloadState,
}
/// What a download's state says under its name: how much of how much, how fast, and how long is left.
pub(crate) fn status(download: &Download) -> String {
let of = |share: f32| {
let done = (download.bytes as f64 * f64::from(share)) as u64;
format!(
"{} of {}",
file_size(done, false),
file_size(download.bytes, false)
)
};
match &download.state {
DownloadState::Queued => "Queued".to_string(),
DownloadState::Fetching(share, 0) => of(*share),
DownloadState::Fetching(share, speed) => {
let left = (download.bytes as f64 * f64::from(1.0 - share)) as u64 / speed;
format!(
"{} · {}/s · about {} left",
of(*share),
file_size(*speed, false),
duration(left.max(1), DurationStyle::Compact)
)
}
DownloadState::Paused(share) => format!(
"Paused at {} · {}",
percent(f64::from(*share), 0, false),
of(*share)
),
DownloadState::Done => file_size(download.bytes, false),
DownloadState::Failed(reason) => format!("Failed · {reason}"),
}
}
/// Models on their way to this machine: each row its name, how much of how much with speed and time left, a bar while it comes; Pause and Resume, Cancel, Retry once it fails, and Delete once it is here.
#[derive(IntoElement)]
pub struct ModelDownloadManager {
id: ElementId,
downloads: Vec<Download>,
on_pause: Option<OnValue>,
on_resume: Option<OnValue>,
on_cancel: Option<OnValue>,
on_retry: Option<OnValue>,
on_delete: Option<OnValue>,
}
impl ModelDownloadManager {
pub fn new(id: impl Into<ElementId>, downloads: impl IntoIterator<Item = Download>) -> Self {
let downloads: Vec<Download> = downloads.into_iter().collect();
for download in &downloads {
if let DownloadState::Fetching(share, _) | DownloadState::Paused(share) = download.state
{
assert!(
(0.0..=1.0).contains(&share),
"download {} at {share} of 1",
download.key
);
}
}
Self {
id: id.into(),
downloads,
on_pause: None,
on_resume: None,
on_cancel: None,
on_retry: None,
on_delete: None,
}
}
pub fn on_pause(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_pause = Some(Rc::new(handler));
self
}
pub fn on_resume(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_resume = Some(Rc::new(handler));
self
}
pub fn on_cancel(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_cancel = Some(Rc::new(handler));
self
}
pub fn on_retry(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_retry = Some(Rc::new(handler));
self
}
/// Gets a downloaded model to take off this machine.
pub fn on_delete(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_delete = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ModelDownloadManager {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let icon = |key: &SharedString,
what: &'static str,
glyph: IconName,
tip: &'static str,
handler: &Option<OnValue>| {
handler.clone().map(|handler| {
let key = key.clone();
IconButton::new((self.id.clone(), format!("{what}-{key}")), glyph)
.size(ControlSize::Sm)
.tooltip(tip)
.on_click(move |_, window, cx| {
log::info!("model downloads: {what} {key}");
handler(&key, window, cx)
})
.into_any_element()
})
};
let rows = self.downloads.iter().map(|download| {
let key = &download.key;
let actions: Vec<_> = match &download.state {
DownloadState::Fetching(..) => vec![
icon(key, "pause", IconName::Pause, "Pause", &self.on_pause),
icon(key, "cancel", IconName::X, "Cancel", &self.on_cancel),
],
DownloadState::Paused(_) => vec![
icon(key, "resume", IconName::Play, "Resume", &self.on_resume),
icon(key, "cancel", IconName::X, "Cancel", &self.on_cancel),
],
DownloadState::Queued => {
vec![icon(key, "cancel", IconName::X, "Cancel", &self.on_cancel)]
}
DownloadState::Failed(_) => vec![
self.on_retry.clone().map(|retry| {
let key = key.clone();
Button::new((self.id.clone(), format!("retry-{key}")), "Retry")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("model downloads: retry {key}");
retry(&key, window, cx)
})
.into_any_element()
}),
icon(key, "delete", IconName::X, "Remove", &self.on_delete),
],
DownloadState::Done => vec![icon(
key,
"delete",
IconName::Trash2,
"Delete from this machine",
&self.on_delete,
)],
};
let bar = match download.state {
DownloadState::Fetching(share, _) | DownloadState::Paused(share) => Some(
ProgressBar::new((self.id.clone(), format!("bar-{key}")), share),
),
_ => None,
};
let failed = matches!(download.state, DownloadState::Failed(_));
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.py_2()
.child(
div()
.flex()
.items_center()
.gap_3()
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(Ellipsis::new(download.name.clone())),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(if failed {
colors.danger
} else {
colors.fg_muted
})
.child(Ellipsis::new(status(download))),
),
)
.child(
div()
.flex_none()
.flex()
.items_center()
.gap_1()
.children(actions.into_iter().flatten()),
),
)
.children(bar)
});
div().w_full().flex().flex_col().children(rows)
}
}
#[cfg(test)]
mod tests {
use super::{Download, DownloadState, status};
fn download(state: DownloadState) -> Download {
Download {
key: "model".into(),
name: "Orchid 8B".into(),
bytes: 4_000_000_000,
state,
}
}
#[test]
fn a_status_says_how_much_how_fast_and_how_long() {
let fetching = download(DownloadState::Fetching(0.25, 40_000_000));
assert_eq!(
status(&fetching),
"1.0 GB of 4.0 GB · 40 MB/s · about 1m 15s left"
);
assert_eq!(
status(&download(DownloadState::Fetching(0.5, 0))),
"2.0 GB of 4.0 GB"
);
assert_eq!(
status(&download(DownloadState::Paused(0.5))),
"Paused at 50% · 2.0 GB of 4.0 GB"
);
assert_eq!(status(&download(DownloadState::Done)), "4.0 GB");
assert_eq!(
status(&download(DownloadState::Failed("disk full".into()))),
"Failed · disk full"
);
}
}
+77
View File
@@ -0,0 +1,77 @@
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::*,
};
use crate::{
data_display::{Meter, Sparkline},
theme::ActiveTheme,
};
/// One part of the machine: its name, how full it is from 0 to 1, that in units, and its recent shares, oldest first.
#[derive(Clone, Debug, PartialEq)]
pub struct Reading {
pub name: SharedString,
pub share: f32,
pub detail: SharedString,
pub history: Vec<f32>,
}
/// The machine at work: a meter for each part, as GPU, its memory and the CPU, that turns amber then red as it fills, and beside it a line of its recent history.
#[derive(IntoElement)]
pub struct HardwareMonitor {
id: ElementId,
readings: Vec<Reading>,
}
impl HardwareMonitor {
pub fn new(id: impl Into<ElementId>, readings: impl IntoIterator<Item = Reading>) -> Self {
let readings: Vec<Reading> = readings.into_iter().collect();
for reading in &readings {
let within = |share: &f32| (0.0..=1.0).contains(share);
assert!(
within(&reading.share) && reading.history.iter().all(within),
"{} reads outside 0 to 1",
reading.name
);
}
Self {
id: id.into(),
readings,
}
}
}
impl RenderOnce for HardwareMonitor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let spark = cx.theme().spark_size();
div().w_full().flex().flex_col().gap_3().children(
self.readings.into_iter().enumerate().map(|(ix, reading)| {
div()
.flex()
.items_end()
.gap_3()
.child(
div().flex_1().min_w_0().child(
Meter::new(
(self.id.clone(), format!("meter-{ix}")),
reading.name,
reading.share,
)
.detail(reading.detail)
.thresholds(0.75, 0.9),
),
)
.when(reading.history.len() > 1, |row| {
row.child(
div()
.flex_none()
.w(spark.width)
.h(spark.height)
.child(Sparkline::new(reading.history).area()),
)
})
}),
)
}
}
+8
View File
@@ -1,8 +1,11 @@
mod audio;
mod brush;
mod compare;
mod downloads;
mod grid;
mod hardware;
mod mask;
mod models;
mod prompt;
mod queue;
mod ratio;
@@ -10,6 +13,7 @@ mod seed;
mod style;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod tuning;
mod variation;
mod video;
mod voice;
@@ -17,13 +21,17 @@ mod voice;
pub use audio::AudioGenerationPlayer;
pub use brush::MaskBrush;
pub use compare::{ABCompareView, Verdict};
pub use downloads::{Download, DownloadState, ModelDownloadManager};
pub use grid::{GenerationGrid, Outcome};
pub use hardware::{HardwareMonitor, Reading};
pub use mask::{InpaintCanvas, MaskStroke};
pub use models::{ModelCard, ModelState, ModelStatus};
pub use prompt::PromptEnhancer;
pub use queue::{GenerationQueue, Job, JobState};
pub use ratio::AspectRatioPicker;
pub use seed::SeedInput;
pub use style::{StylePreset, StylePresetPicker};
pub use tuning::{FineTuneJobCard, TunePhase};
pub use variation::VariationPicker;
pub use video::{Shot, VideoGenerationTimeline};
pub use voice::{TTSVoicePicker, Voice};
+303
View File
@@ -0,0 +1,303 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
data_display::{Badge, DescriptionList, Meter, Tone},
forms::Run,
motion::{ProgressBar, Spinner},
primitives::Severity,
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{
Ellipsis,
format::{file_size, percent},
},
};
/// A model to choose: its name and maker, a line on what it does, facts such as its size and license, tags for what it can do, and Use unless it is in use.
#[derive(IntoElement)]
pub struct ModelCard {
id: ElementId,
name: SharedString,
maker: SharedString,
about: SharedString,
facts: Vec<(SharedString, SharedString)>,
tags: Vec<SharedString>,
in_use: bool,
on_use: Option<Run>,
}
impl ModelCard {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
maker: impl Into<SharedString>,
about: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
maker: maker.into(),
about: about.into(),
facts: Vec::new(),
tags: Vec::new(),
in_use: false,
on_use: None,
}
}
/// A fact and its value, such as "Parameters" and "8B".
pub fn fact(mut self, label: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
self.facts.push((label.into(), value.into()));
self
}
/// Something it can do, such as "Vision".
pub fn tag(mut self, tag: impl Into<SharedString>) -> Self {
self.tags.push(tag.into());
self
}
pub fn in_use(mut self, in_use: bool) -> Self {
self.in_use = in_use;
self
}
pub fn on_use(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_use = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ModelCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let label = theme.label_width();
let facts = self
.facts
.into_iter()
.fold(DescriptionList::new(), |list, (label, value)| {
list.item(label, value)
});
let (name, in_use) = (self.name.clone(), self.in_use);
let choose = self.on_use.filter(|_| !in_use).map(|choose| {
Button::new((self.id.clone(), "use"), "Use")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("model card: use {name}");
choose(window, cx)
})
});
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(
div()
.flex()
.flex_wrap()
.items_start()
.gap_x_3()
.gap_y_2()
.child(
div()
.flex_1()
.min_w(label)
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Base))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(Ellipsis::new(self.name)),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(Ellipsis::new(self.maker)),
),
)
.child(
div()
.flex_none()
.children(in_use.then(|| Badge::new("In use").tone(Tone::Success)))
.children(choose),
),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(self.about),
)
.child(facts)
.when(!self.tags.is_empty(), |card| {
card.child(
div()
.flex()
.flex_wrap()
.gap_1()
.children(self.tags.into_iter().map(Badge::new)),
)
})
}
}
/// Where a model stands on this machine.
#[derive(Clone, Debug, PartialEq)]
pub enum ModelState {
Idle,
Loading(f32),
Ready,
Failed(SharedString),
}
impl ModelState {
fn word(&self) -> String {
match self {
Self::Idle => "Not loaded".to_string(),
Self::Loading(share) => format!("Loading · {}", percent(f64::from(*share), 0, false)),
Self::Ready => "Loaded".to_string(),
Self::Failed(_) => "Failed".to_string(),
}
}
}
/// A model on this machine: a mark and a word for where it stands, a bar while it loads, and once loaded, the GPU memory it holds.
#[derive(IntoElement)]
pub struct ModelStatus {
id: ElementId,
name: SharedString,
state: ModelState,
memory: Option<(u64, u64)>,
}
impl ModelStatus {
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, state: ModelState) -> Self {
if let ModelState::Loading(share) = state {
assert!(
(0.0..=1.0).contains(&share),
"a model loaded to {share} of 1"
);
}
Self {
id: id.into(),
name: name.into(),
state,
memory: None,
}
}
/// GPU memory it holds and the card's whole, in bytes.
pub fn memory(mut self, used: u64, total: u64) -> Self {
assert!(total > 0 && used <= total, "GPU memory {used} of {total}");
self.memory = Some((used, total));
self
}
}
impl RenderOnce for ModelStatus {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let tone = match self.state {
ModelState::Idle => None,
ModelState::Loading(_) => Some(Severity::Info),
ModelState::Ready => Some(Severity::Success),
ModelState::Failed(_) => Some(Severity::Danger),
};
let ink = tone.map_or(colors.fg_subtle, |tone| tone.color(&colors));
let mark = match self.state {
ModelState::Loading(_) => Spinner::new((self.id.clone(), "loading"))
.size(IconSize::Xs)
.color(ink)
.into_any_element(),
_ => div()
.size(theme.status_dot())
.rounded_full()
.when(tone.is_some(), |dot| dot.bg(ink))
.when(tone.is_none(), |dot| dot.border_1().border_color(ink))
.into_any_element(),
};
let below = match (&self.state, self.memory) {
(ModelState::Loading(share), _) => {
Some(ProgressBar::new((self.id.clone(), "load"), *share).into_any_element())
}
(ModelState::Failed(reason), _) => Some(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(ink)
.child(Ellipsis::new(reason.clone()))
.into_any_element(),
),
(ModelState::Ready, Some((used, total))) => Some(
Meter::new(
(self.id.clone(), "memory"),
"GPU memory",
used as f32 / total as f32,
)
.detail(format!(
"{} of {}",
file_size(used, false),
file_size(total, false)
))
.into_any_element(),
),
_ => None,
};
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_none()
.w(theme.icon_size(IconSize::Xs))
.flex()
.justify_center()
.child(mark),
)
.child(
div()
.flex_1()
.min_w_0()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(Ellipsis::new(self.name)),
)
.child(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(if tone == Some(Severity::Danger) {
ink
} else {
colors.fg_muted
})
.child(self.state.word()),
),
)
.children(below)
}
}
+1
View File
@@ -3,6 +3,7 @@ use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext}
use crate::{forms, primitives::FocusNext, theme::Theme};
mod canvas;
mod models;
mod results;
mod settings;
mod sound;
+139
View File
@@ -0,0 +1,139 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{Context, IntoElement, ParentElement, Render, Styled, TestAppContext, Window, div, px};
use super::{press, settle, setup, tab};
use crate::generative::{
Download, DownloadState, FineTuneJobCard, HardwareMonitor, ModelCard, ModelDownloadManager,
ModelState, ModelStatus, Reading, TunePhase,
};
type Heard = Rc<RefCell<Vec<String>>>;
/// Downloads in each state, and the actions heard.
struct Downloads(Heard);
impl Render for Downloads {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let download = |key: &str, state| Download {
key: key.to_string().into(),
name: key.to_string().into(),
bytes: 1_000_000,
state,
};
let heard = |what: &'static str| {
let heard = self.0.clone();
move |key: &gpui::SharedString, _: &mut Window, _: &mut gpui::App| {
heard.borrow_mut().push(format!("{what} {key}"))
}
};
div().w(px(420.0)).child(
ModelDownloadManager::new(
"downloads",
[
download("a", DownloadState::Fetching(0.5, 1_000)),
download("b", DownloadState::Paused(0.2)),
download("c", DownloadState::Failed("disk full".into())),
download("d", DownloadState::Done),
],
)
.on_pause(heard("pause"))
.on_resume(heard("resume"))
.on_cancel(heard("cancel"))
.on_retry(heard("retry"))
.on_delete(heard("delete")),
)
}
}
#[gpui::test]
fn each_download_offers_what_its_state_allows(cx: &mut TestAppContext) {
setup(cx);
let heard = Heard::default();
let store = heard.clone();
let (_, cx) = cx.add_window_view(|_, _| Downloads(store));
cx.update(|window, _| window.activate_window());
settle(cx);
for _ in 0..7 {
tab(1, cx);
press("enter", cx);
}
assert_eq!(
*heard.borrow(),
[
"pause a", "cancel a", "resume b", "cancel b", "retry c", "delete c", "delete d"
]
);
}
/// A model to use and one in use, a job training for one epoch and one done, and what they heard.
struct Cards(Heard);
impl Render for Cards {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let (used, canceled, opened) = (self.0.clone(), self.0.clone(), self.0.clone());
div()
.w(px(420.0))
.child(
ModelCard::new("free", "Orchid", "Orchid Labs", "A model")
.on_use(move |_, _| used.borrow_mut().push("use".into())),
)
.child(
ModelCard::new("busy", "Juniper", "Juniper Co", "A model")
.in_use(true)
.on_use(|_, _| {}),
)
.child(
FineTuneJobCard::new(
"training",
"tone",
"Orchid",
"notes",
TunePhase::Running(1, 1, 0.5),
)
.on_cancel(move |_, _| canceled.borrow_mut().push("cancel".into())),
)
.child(
FineTuneJobCard::new("done", "tone", "Orchid", "notes", TunePhase::Done)
.loss(vec![1.2, 0.8, 0.6], vec![1.3, 0.9, 0.7])
.on_open(move |_, _| opened.borrow_mut().push("open".into())),
)
}
}
#[gpui::test]
fn cards_offer_what_their_state_allows(cx: &mut TestAppContext) {
setup(cx);
let heard = Heard::default();
let store = heard.clone();
let (_, cx) = cx.add_window_view(|_, _| Cards(store));
cx.update(|window, _| window.activate_window());
settle(cx);
for _ in 0..3 {
tab(1, cx);
press("enter", cx);
}
assert_eq!(
*heard.borrow(),
["use", "cancel", "open"],
"a model in use offers no Use"
);
}
#[test]
#[should_panic(expected = "GPU memory 5 of 4")]
fn memory_past_the_card_fails_loud() {
let _ = ModelStatus::new("model", "Orchid", ModelState::Ready).memory(5, 4);
}
#[test]
#[should_panic(expected = "GPU reads outside 0 to 1")]
fn a_reading_past_full_fails_loud() {
let reading = Reading {
name: "GPU".into(),
share: 1.2,
detail: "120%".into(),
history: Vec::new(),
};
let _ = HardwareMonitor::new("machine", [reading]);
}
+230
View File
@@ -0,0 +1,230 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
charts::{LineChart, Series},
data_display::{Badge, Tone},
forms::Run,
motion::ProgressBar,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{Ellipsis, format::percent, tabular},
};
/// Where a fine-tuning job stands.
#[derive(Clone, Debug, PartialEq)]
pub enum TunePhase {
/// Waiting, with this many ahead of it.
Queued(usize),
/// Training: the epoch under way from one, of how many, and the share of it done.
Running(usize, usize, f32),
Done,
Failed(SharedString),
Canceled,
}
impl TunePhase {
fn badge(&self) -> (&'static str, Tone) {
match self {
Self::Queued(_) => ("Queued", Tone::Neutral),
Self::Running(..) => ("Training", Tone::Info),
Self::Done => ("Done", Tone::Success),
Self::Failed(_) => ("Failed", Tone::Danger),
Self::Canceled => ("Canceled", Tone::Neutral),
}
}
}
/// A fine-tuning job: its name, the base model and data it learns from, where it stands, how far it has come by epoch, and its loss on training and held-out data. Cancel while it waits or trains; Open once done.
#[derive(IntoElement)]
pub struct FineTuneJobCard {
id: ElementId,
name: SharedString,
base: SharedString,
data: SharedString,
phase: TunePhase,
train: Vec<f64>,
held: Vec<f64>,
on_cancel: Option<Run>,
on_open: Option<Run>,
}
impl FineTuneJobCard {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
base: impl Into<SharedString>,
data: impl Into<SharedString>,
phase: TunePhase,
) -> Self {
if let TunePhase::Running(epoch, epochs, share) = phase {
assert!(
(1..=epochs).contains(&epoch) && (0.0..=1.0).contains(&share),
"epoch {epoch} of {epochs} at {share}"
);
}
Self {
id: id.into(),
name: name.into(),
base: base.into(),
data: data.into(),
phase,
train: Vec::new(),
held: Vec::new(),
on_cancel: None,
on_open: None,
}
}
/// Loss at each checkpoint, on training data and on held-out data, one value each.
pub fn loss(mut self, train: impl Into<Vec<f64>>, held: impl Into<Vec<f64>>) -> Self {
let (train, held) = (train.into(), held.into());
assert_eq!(
train.len(),
held.len(),
"a loss for each checkpoint on both sets"
);
self.train = train;
self.held = held;
self
}
pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_cancel = Some(Rc::new(handler));
self
}
pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FineTuneJobCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let (word, tone) = self.phase.badge();
let live = matches!(self.phase, TunePhase::Queued(_) | TunePhase::Running(..));
let action = |key: &'static str, label: &'static str, run: Option<Run>| {
run.map(|run| {
Button::new((self.id.clone(), key), label)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("fine-tune job: {key}");
run(window, cx)
})
})
};
let actions = match self.phase {
TunePhase::Done => action("open", "Open", self.on_open),
_ if live => action("cancel", "Cancel", self.on_cancel),
_ => None,
};
let progress = match &self.phase {
TunePhase::Running(epoch, epochs, share) => {
let whole = ((*epoch - 1) as f32 + share) / *epochs as f32;
Some(
div()
.flex()
.flex_col()
.gap_1p5()
.child(match *epochs {
1 => ProgressBar::new((self.id.clone(), "epochs"), whole),
_ => ProgressBar::new((self.id.clone(), "epochs"), whole)
.segments(*epochs),
})
.child(
tabular(div())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(format!(
"Epoch {epoch} of {epochs} · {}",
percent(f64::from(*share), 0, false)
)),
)
.into_any_element(),
)
}
TunePhase::Queued(ahead) => Some(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(format!("{ahead} ahead"))
.into_any_element(),
),
TunePhase::Failed(reason) => Some(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.danger)
.child(reason.clone())
.into_any_element(),
),
_ => None,
};
let chart = (self.train.len() > 1).then(|| {
let steps: Vec<SharedString> = (1..=self.train.len())
.map(|step| step.to_string().into())
.collect();
LineChart::new((self.id.clone(), "loss"), steps)
.series(Series::new("Training", self.train))
.series(Series::new("Held out", self.held))
.format(|value| format!("{value:.2}"))
.h(theme.generative().loss)
});
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(
div()
.flex()
.flex_wrap()
.items_start()
.gap_x_3()
.gap_y_2()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(Ellipsis::new(self.name)),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(Ellipsis::new(format!("{} · {}", self.base, self.data))),
),
)
.child(
div()
.flex_none()
.flex()
.items_center()
.gap_2()
.child(Badge::new(word).tone(tone))
.children(actions),
),
)
.children(progress)
.children(chart)
}
}