diff --git a/AGENTS.md b/AGENTS.md index 439be6d..034161a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `cargo run --example gallery` opens the gallery. `-- --page ` starts on a page. - `cargo run --example gallery -- --capture ` writes PNGs of every page, light and dark, top to bottom, then each page's scripted states, including windows the demos open. macOS only. - `cargo test --lib --features test-support`, `cargo clippy --all-targets --features test-support -- -D warnings`, `cargo fmt --check`. `scripts/check.sh` runs them with the house rules. -- `rm -rf target/debug/incremental` after each task item keeps the disk lean. +- `rm -rf target/debug/incremental` and `cargo clean -p ely-gpui-component` after each task item keep the disk lean. Cargo keeps every old build of the crate and its tests under a new hash; by T25 they had filled the disk. - `scripts/icons.sh ...` adds icons. Then add a line to `IconName` in `src/primitives/icon.rs`. ## Layout @@ -83,7 +83,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. - A stroke drawn with the pointer goes through `forms::pen`: a press starts it, a drag extends it, and the lift hands it over, in or out of the pad (`SignaturePad`, `generative::InpaintCanvas`). - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. -- `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus (`tables::header`). Never poll layout with `request_animation_frame`. +- `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus, through `layout::bring_into_view`. Never poll layout with `request_animation_frame`. - gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view. - Machine-read marks, QR codes and barcodes, paint `ink` on `paper`: dark on light in both themes, so cameras read them. - A layer over pressable content that must still pass scrolls to its ancestors uses `block_mouse_except_scroll`; `occlude` hides them from scrolls too. diff --git a/examples/gallery/pages/generative/mod.rs b/examples/gallery/pages/generative/mod.rs index 99073a1..0ccae50 100644 --- a/examples/gallery/pages/generative/mod.rs +++ b/examples/gallery/pages/generative/mod.rs @@ -1,4 +1,5 @@ mod canvas; +mod models; mod panel; mod results; mod sound; @@ -47,5 +48,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(sound::audio(window, cx)) .child(sound::voice(window, cx)) .child(sound::video(window, cx)) + .child(models::cards(window, cx)) + .child(models::downloads_section(window, cx)) + .child(models::machine(window, cx)) + .child(models::tuning(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/generative/models.rs b/examples/gallery/pages/generative/models.rs new file mode 100644 index 0000000..1c9ff04 --- /dev/null +++ b/examples/gallery/pages/generative/models.rs @@ -0,0 +1,221 @@ +use ely_gpui_component::generative::{ + Download, DownloadState, FineTuneJobCard, HardwareMonitor, ModelCard, ModelDownloadManager, + ModelState, ModelStatus, Reading, TunePhase, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +pub fn cards(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let using = keep( + "gen-model-used", + || SharedString::from("orchid"), + window, + cx, + ); + let now = using.read(cx).clone(); + let card = |key: &'static str, name: &str, maker: &str, about: &str, size: &str| { + let pick = using.clone(); + ModelCard::new( + SharedString::from(format!("gen-model-{key}")), + name.to_string(), + maker.to_string(), + about.to_string(), + ) + .fact("Parameters", size.to_string()) + .fact("Context", "128K tokens") + .fact("License", "Apache-2.0") + .in_use(now == key) + .on_use(move |_, cx| set(&pick, SharedString::from(key), cx)) + }; + section( + "ModelCard", + "A model to choose: its maker, what it does, facts such as its size and license, and what it can do. The one in use says so; the others offer Use.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_4() + .child(div().w(px(340.)).child(card("orchid", "Orchid 8B", "Orchid Labs", "Writes and reasons in plain words; runs on one card.", "8B").tag("Tools").tag("Vision"))) + .child(div().w(px(340.)).child(card("juniper", "Juniper 70B", "Juniper Co", "Long, careful answers for research and code.", "70B").tag("Tools"))), + ) +} + +fn downloads() -> Vec { + let download = |key: &str, name: &str, bytes: u64, state| Download { + key: key.to_string().into(), + name: name.to_string().into(), + bytes, + state, + }; + vec![ + download( + "orchid", + "Orchid 8B · Q4", + 4_900_000_000, + DownloadState::Fetching(0.43, 38_000_000), + ), + download( + "juniper", + "Juniper 70B · Q4", + 39_000_000_000, + DownloadState::Paused(0.12), + ), + download( + "voice", + "Iris voice pack", + 310_000_000, + DownloadState::Queued, + ), + download( + "embed", + "Lichen embeddings", + 640_000_000, + DownloadState::Failed("the disk is full".into()), + ), + download("tiny", "Orchid 1B · Q8", 1_300_000_000, DownloadState::Done), + ] +} + +pub fn downloads_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let list = keep("gen-downloads", downloads, window, cx); + let now = list.read(cx).clone(); + let change = |list: &gpui::Entity>| { + let list = list.clone(); + move |key: &SharedString, state: Option, cx: &mut App| { + let mut next = list.read(cx).clone(); + match state { + Some(state) => { + let download = next + .iter_mut() + .find(|download| &download.key == key) + .expect("a listed download"); + download.state = state; + } + None => next.retain(|download| &download.key != key), + } + set(&list, next, cx) + } + }; + let (pause, resume, cancel, retry, delete) = ( + change(&list), + change(&list), + change(&list), + change(&list), + change(&list), + ); + let (at_pause, at_resume) = (list.clone(), list.clone()); + section( + "ModelDownloadManager", + "Models on their way to this machine: how much of how much, how fast and how long is left, with Pause and Resume, Cancel, Retry, and Delete once a model is here.", + cx, + ) + .child(probe( + "gen-downloads", + div().w(px(480.)).child( + ModelDownloadManager::new("gen-downloads", now) + .on_pause(move |key, _, cx| { + let at = held(&at_pause, key, cx); + pause(key, Some(DownloadState::Paused(at)), cx) + }) + .on_resume(move |key, _, cx| { + let at = held(&at_resume, key, cx); + resume(key, Some(DownloadState::Fetching(at, 38_000_000)), cx) + }) + .on_cancel(move |key, _, cx| cancel(key, None, cx)) + .on_retry(move |key, _, cx| retry(key, Some(DownloadState::Queued), cx)) + .on_delete(move |key, _, cx| delete(key, None, cx)), + ), + )) +} + +/// The share a download has fetched, or none yet. +fn held(list: &gpui::Entity>, key: &SharedString, cx: &App) -> f32 { + match list + .read(cx) + .iter() + .find(|download| &download.key == key) + .map(|download| &download.state) + { + Some(DownloadState::Fetching(share, _) | DownloadState::Paused(share)) => *share, + _ => 0.0, + } +} + +pub fn machine(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let history = |values: &[f32]| values.to_vec(); + section( + "ModelStatus / HardwareMonitor", + "Models on this machine, loaded, loading or not, with the GPU memory each holds; and the machine at work, each part's meter turning amber then red as it fills, beside its recent history.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + div() + .w(px(320.)) + .flex() + .flex_col() + .gap_4() + .child(ModelStatus::new("gen-status-orchid", "Orchid 8B", ModelState::Ready).memory(7_800_000_000, 24_000_000_000)) + .child(ModelStatus::new("gen-status-juniper", "Juniper 70B", ModelState::Loading(0.62))) + .child(ModelStatus::new("gen-status-iris", "Iris voice", ModelState::Idle)), + ) + .child( + div().w(px(360.)).child(HardwareMonitor::new( + "gen-machine", + [ + Reading { name: "GPU".into(), share: 0.72, detail: "72%".into(), history: history(&[0.2, 0.35, 0.6, 0.55, 0.7, 0.8, 0.72]) }, + Reading { name: "GPU memory".into(), share: 0.93, detail: "22.3 GB of 24 GB".into(), history: history(&[0.5, 0.6, 0.8, 0.9, 0.92, 0.93, 0.93]) }, + Reading { name: "CPU".into(), share: 0.31, detail: "31%".into(), history: history(&[0.4, 0.3, 0.25, 0.35, 0.3, 0.28, 0.31]) }, + ], + )), + ), + ) +} + +pub fn tuning(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let canceled = keep("gen-tune-canceled", || false, window, cx); + let stopped = *canceled.read(cx); + let phase = if stopped { + TunePhase::Canceled + } else { + TunePhase::Running(2, 3, 0.64) + }; + section( + "FineTuneJobCard", + "A fine-tuning job: 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.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_4() + .child( + div().w(px(380.)).child( + FineTuneJobCard::new("gen-tune", "Plain-words tone", "Orchid 8B", "notes-2026.jsonl · 1,240 examples", phase) + .loss(vec![1.9, 1.4, 1.1, 0.92, 0.81, 0.74], vec![2.0, 1.5, 1.2, 1.02, 0.95, 0.91]) + .on_cancel(move |_, cx| set(&canceled, true, cx)), + ), + ) + .child( + div().w(px(380.)).child( + FineTuneJobCard::new("gen-tune-done", "Support replies", "Juniper 70B", "tickets.jsonl · 8,900 examples", TunePhase::Done) + .loss(vec![1.6, 1.1, 0.8, 0.62, 0.55, 0.51], vec![1.7, 1.2, 0.9, 0.74, 0.69, 0.68]) + .on_open(|_, _| log::info!("gallery: open the tuned model")), + ), + ), + ) +} diff --git a/src/generative/downloads.rs b/src/generative/downloads.rs new file mode 100644 index 0000000..c55636e --- /dev/null +++ b/src/generative/downloads.rs @@ -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, + on_pause: Option, + on_resume: Option, + on_cancel: Option, + on_retry: Option, + on_delete: Option, +} + +impl ModelDownloadManager { + pub fn new(id: impl Into, downloads: impl IntoIterator) -> Self { + let downloads: Vec = 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| { + 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" + ); + } +} diff --git a/src/generative/hardware.rs b/src/generative/hardware.rs new file mode 100644 index 0000000..d842389 --- /dev/null +++ b/src/generative/hardware.rs @@ -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, +} + +/// 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, +} + +impl HardwareMonitor { + pub fn new(id: impl Into, readings: impl IntoIterator) -> Self { + let readings: Vec = 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()), + ) + }) + }), + ) + } +} diff --git a/src/generative/mod.rs b/src/generative/mod.rs index c34d44d..98faddd 100644 --- a/src/generative/mod.rs +++ b/src/generative/mod.rs @@ -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}; diff --git a/src/generative/models.rs b/src/generative/models.rs new file mode 100644 index 0000000..75e9f7a --- /dev/null +++ b/src/generative/models.rs @@ -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, + in_use: bool, + on_use: Option, +} + +impl ModelCard { + pub fn new( + id: impl Into, + name: impl Into, + maker: impl Into, + about: impl Into, + ) -> 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, value: impl Into) -> Self { + self.facts.push((label.into(), value.into())); + self + } + + /// Something it can do, such as "Vision". + pub fn tag(mut self, tag: impl Into) -> 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, name: impl Into, 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) + } +} diff --git a/src/generative/tests/mod.rs b/src/generative/tests/mod.rs index 8985404..766c4e1 100644 --- a/src/generative/tests/mod.rs +++ b/src/generative/tests/mod.rs @@ -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; diff --git a/src/generative/tests/models.rs b/src/generative/tests/models.rs new file mode 100644 index 0000000..62820fb --- /dev/null +++ b/src/generative/tests/models.rs @@ -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>>; + +/// Downloads in each state, and the actions heard. +struct Downloads(Heard); + +impl Render for Downloads { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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) -> 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]); +} diff --git a/src/generative/tuning.rs b/src/generative/tuning.rs new file mode 100644 index 0000000..c99a3ab --- /dev/null +++ b/src/generative/tuning.rs @@ -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, + held: Vec, + on_cancel: Option, + on_open: Option, +} + +impl FineTuneJobCard { + pub fn new( + id: impl Into, + name: impl Into, + base: impl Into, + data: impl Into, + 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>, held: impl Into>) -> 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.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 = (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) + } +} diff --git a/src/theme/generative.rs b/src/theme/generative.rs index 55203a6..d2ac06a 100644 --- a/src/theme/generative.rs +++ b/src/theme/generative.rs @@ -11,6 +11,8 @@ pub struct GenerativeSizes { pub second: Rems, /// A shot's height on a timeline. pub shot: Rems, + /// A loss chart's height on a job's card. + pub loss: Rems, } impl Theme { @@ -19,6 +21,7 @@ impl Theme { preset: px_to_rems(88.0), second: px_to_rems(24.0), shot: px_to_rems(56.0), + loss: px_to_rems(140.0), } } } diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 420a0e3..fa70b54 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -238,11 +238,11 @@ ## T25e Generative · Models -- [ ] ModelCard -- [ ] ModelDownloadManager -- [ ] ModelStatus -- [ ] HardwareMonitor -- [ ] FineTuneJobCard +- [x] ModelCard +- [x] ModelDownloadManager +- [x] ModelStatus +- [x] HardwareMonitor +- [x] FineTuneJobCard ## T25f Generative · Data and prompts