Add resumable background model downloads

This commit is contained in:
Georg Bauer
2026-07-24 13:10:04 +02:00
parent 62e7752ec7
commit 952fb79edc
12 changed files with 1265 additions and 115 deletions

View File

@@ -1,9 +1,10 @@
use super::{App, MODEL_CHOICES, Message, ModelChoice};
use super::{ActiveDownload, App, Message, ModelDownload, models_path};
use crate::database::{ProjectWithSessions, Session};
use crate::model::{self, DownloadPhase, DownloadProgress, MODEL_CHOICES, ModelChoice};
use iced::theme::{Palette, palette};
use iced::widget::{
Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack,
svg, text, text_input,
Space, Svg, button, checkbox, column, container, opaque, pick_list, progress_bar, row,
scrollable, stack, svg, text, text_input,
};
use iced::{Alignment, Color, Element, Length, Theme};
use std::path::Path;
@@ -27,6 +28,9 @@ impl App {
.width(Length::Fill)
.height(Length::Fill)
];
if let ModelDownload::Active(download) = &self.model_download {
shell = shell.push(download_status_bar(download));
}
if let Some(error) = &self.error {
shell = shell.push(
container(text(error).style(iced::widget::text::danger))
@@ -277,16 +281,43 @@ impl App {
}
fn preferences_panel(&self) -> Element<'_, Message> {
let dflash_toggle: Option<fn(bool) -> Message> = self
let dspark_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.model
.supports_dflash()
.then_some(Message::PreferenceDflashChanged);
let dflash = checkbox(
"Enable DFlash for this model",
self.preference_draft.dflash_enabled,
.supports_dspark()
.then_some(Message::PreferenceDsparkChanged);
let dspark = checkbox(
"Enable DSpark for this model",
self.preference_draft.dspark_enabled,
)
.on_toggle_maybe(dflash_toggle);
.on_toggle_maybe(dspark_toggle);
let model = self.preference_draft.model;
let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled;
let selected_progress = model::download_progress(model, dspark_enabled, &models_path());
let installed = selected_progress.phase == DownloadPhase::Complete;
let download = if let ModelDownload::Active(download) = &self.model_download {
if download.stopping {
button("Stopping…")
} else {
button("Stop download")
.on_press(Message::StopModelDownload)
.style(button::danger)
}
} else if installed {
button("Downloaded")
} else {
let action = if selected_progress.downloaded > 0 {
"Resume"
} else {
"Download"
};
button(text(format!(
"{action} {} ({})",
model,
format_bytes(selected_progress.total)
)))
.on_press(Message::DownloadSelectedModel)
};
let mut content = column![
row![
@@ -305,13 +336,15 @@ impl App {
Message::PreferenceModelChanged,
)
.width(Length::Fill),
dflash,
text(if self.preference_draft.model.supports_dflash() {
"DFlash is downloaded and used only when enabled."
dspark,
text(if self.preference_draft.model.supports_dspark() {
"DSpark support is downloaded and used only when enabled."
} else {
"DFlash is not available for the selected model."
"DSpark is not available for the selected model."
})
.size(12),
download.style(button::secondary),
model_download_status(&self.model_download, &selected_progress),
Space::with_height(8),
text("INACTIVITY").size(11),
row![
@@ -408,6 +441,152 @@ impl App {
}
}
fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
let progress = &download.progress;
let percent = progress.fraction() * 100.0;
let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 {
format!(
"{}/s • about {} remaining",
format_bytes(download.bytes_per_second as u64),
format_duration(progress.remaining() as f64 / download.bytes_per_second),
)
} else {
"Calculating time remaining…".to_owned()
};
let stop = if download.stopping {
button("Stopping…")
} else {
button("Stop")
.on_press(Message::StopModelDownload)
.style(button::danger)
};
container(
row![
text(phase_text(progress.phase)).size(12),
progress_bar(0.0..=1.0, progress.fraction())
.width(180)
.height(7),
text(format!(
"{} of {} ({percent:.1}%)",
format_bytes(progress.downloaded),
format_bytes(progress.total),
))
.size(12),
text(transfer).size(12),
Space::with_width(Length::Fill),
stop,
]
.spacing(12)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.padding([7, 14])
.style(sidebar_style)
.into()
}
fn model_download_status<'a>(
download: &ModelDownload,
selected: &DownloadProgress,
) -> Element<'a, Message> {
let (progress, heading, speed, failed) = match download {
ModelDownload::Idle => (
selected,
if selected.downloaded > 0 {
"Paused — the next download will resume from this point.".to_owned()
} else {
phase_text(selected.phase)
},
0.0,
false,
),
ModelDownload::Active(active) => (
&active.progress,
if active.stopping {
format!("Stopping {}", active.model)
} else {
phase_text(active.progress.phase)
},
active.bytes_per_second,
false,
),
ModelDownload::Complete(model, progress) => (
progress,
format!("{model} is downloaded and verified."),
0.0,
false,
),
ModelDownload::Failed(model, error, progress) => (
progress,
format!("{model} download failed: {error}"),
0.0,
true,
),
};
let percent = progress.fraction() * 100.0;
let mut status = column![
if failed {
text(heading).size(12).style(iced::widget::text::danger)
} else {
text(heading).size(12)
},
progress_bar(0.0..=1.0, progress.fraction()).height(8),
text(format!(
"{} of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.downloaded),
format_bytes(progress.total),
format_bytes(progress.remaining()),
))
.size(12),
]
.spacing(6);
if speed > 0.0 && progress.remaining() > 0 {
status = status.push(
text(format!(
"{}/s • about {} remaining",
format_bytes(speed as u64),
format_duration(progress.remaining() as f64 / speed),
))
.size(12),
);
}
status.into()
}
fn phase_text(phase: DownloadPhase) -> String {
match phase {
DownloadPhase::Pending(artifact) => format!("Ready to download {artifact}."),
DownloadPhase::Downloading(artifact) => format!("Downloading {artifact}"),
DownloadPhase::Verifying(artifact) => format!("Verifying {artifact}"),
DownloadPhase::Complete => "Download complete.".to_owned(),
}
}
fn format_bytes(bytes: u64) -> String {
const GB: f64 = 1_000_000_000.0;
const MB: f64 = 1_000_000.0;
if bytes >= 1_000_000_000 {
format!("{:.1} GB", bytes as f64 / GB)
} else {
format!("{:.1} MB", bytes as f64 / MB)
}
}
fn format_duration(seconds: f64) -> String {
let seconds = seconds.max(0.0).round() as u64;
let hours = seconds / 3600;
let minutes = seconds % 3600 / 60;
if hours > 0 {
format!("{hours}h {minutes}m")
} else if minutes > 0 {
format!("{minutes}m {}s", seconds % 60)
} else {
format!("{seconds}s")
}
}
pub(crate) fn app_theme() -> Theme {
let palette = Palette {
background: Color::from_rgb8(29, 29, 31),
@@ -451,4 +630,11 @@ mod tests {
assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40));
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
}
#[test]
fn download_measurements_are_readable() {
assert_eq!(format_bytes(1_500_000_000), "1.5 GB");
assert_eq!(format_duration(7_384.0), "2h 3m");
assert_eq!(format_duration(125.0), "2m 5s");
}
}