Add model management and macOS integration

This commit is contained in:
Georg Bauer
2026-07-24 15:18:35 +02:00
parent ec77ab94fb
commit ad7167e84a
11 changed files with 1486 additions and 251 deletions

View File

@@ -1,12 +1,14 @@
use super::{ActiveDownload, App, Message, ModelDownload, models_path};
use super::{ActiveDownload, App, Message, ModelDownload, ModelOperation, models_path};
use crate::database::{ProjectWithSessions, Session};
use crate::model::{self, DownloadPhase, DownloadProgress, MODEL_CHOICES, ModelChoice};
use crate::model::{
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,
};
use iced::theme::{Palette, palette};
use iced::widget::{
Space, Svg, button, checkbox, column, container, opaque, pick_list, progress_bar, row,
scrollable, stack, svg, text, text_input,
Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list,
progress_bar, row, scrollable, stack, svg, text, text_input,
};
use iced::{Alignment, Color, Element, Length, Theme};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window};
use std::path::Path;
const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg");
@@ -22,7 +24,15 @@ const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg");
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
impl App {
pub(crate) fn view(&self) -> Element<'_, Message> {
pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> {
if self.model_manager_window == Some(id) {
self.model_manager()
} else {
self.main_view()
}
}
fn main_view(&self) -> Element<'_, Message> {
let mut shell = column![
row![self.sidebar(), self.detail()]
.width(Length::Fill)
@@ -178,11 +188,9 @@ impl App {
.spacing(8)
.align_y(Alignment::Center);
let open_project = if self.database.is_some() && !self.choosing_folder {
button(open_project_content)
.on_press(Message::ChooseProjectFolder)
.style(button::primary)
action_button(open_project_content).on_press(Message::ChooseProjectFolder)
} else {
button(open_project_content)
action_button(open_project_content)
};
return container(
column![
@@ -237,7 +245,7 @@ impl App {
.to_string(),
)
.size(12),
button(icon(ICON_SEND, 18)),
action_button(icon(ICON_SEND, 18)).padding(8),
]
.align_y(Alignment::Center),
]
@@ -245,7 +253,7 @@ impl App {
)
.padding(16)
.width(Length::Fill)
.style(container::rounded_box),
.style(overview_style),
]
.height(Length::Fill)
.spacing(8);
@@ -291,33 +299,6 @@ impl App {
self.preference_draft.dspark_enabled,
)
.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![
@@ -338,13 +319,11 @@ impl App {
.width(Length::Fill),
dspark,
text(if self.preference_draft.model.supports_dspark() {
"DSpark support is downloaded and used only when enabled."
"DSpark support is used only when enabled. Downloads are managed in Model Manager."
} else {
"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![
@@ -366,12 +345,8 @@ impl App {
content = content.push(
row![
Space::with_width(Length::Fill),
button("Cancel")
.on_press(Message::DismissPanel)
.style(button::secondary),
button("Save")
.on_press(Message::SavePreferences)
.style(button::primary),
action_button("Cancel").on_press(Message::DismissPanel),
action_button("Save").on_press(Message::SavePreferences),
]
.spacing(8),
);
@@ -379,7 +354,7 @@ impl App {
let panel = container(content)
.padding(24)
.width(520)
.style(container::rounded_box);
.style(overview_style);
opaque(
container(panel)
.center_x(Length::Fill)
@@ -390,6 +365,79 @@ impl App {
)
}
fn model_manager(&self) -> Element<'_, Message> {
let busy = matches!(self.model_download, ModelDownload::Active(_));
let mut artifacts = column![];
for (index, artifact) in model::managed_artifacts(&models_path()).iter().enumerate() {
if index > 0 {
artifacts = artifacts.push(horizontal_rule(1));
}
artifacts = artifacts.push(model_artifact_row(artifact, busy));
}
let mut content = column![
text("Model Manager").size(26),
text("Download, verify, or remove locally stored model files.")
.size(14)
.color(muted_text()),
]
.spacing(8);
if !matches!(self.model_download, ModelDownload::Idle) {
content = content.push(
container(model_download_status(&self.model_download))
.padding(16)
.style(overview_style),
);
}
if let Some(error) = &self.error {
content = content.push(text(error).style(iced::widget::text::danger));
}
content = content.push(Space::with_height(8)).push(
container(scrollable(artifacts).height(Length::Fill))
.height(Length::Fill)
.style(overview_style),
);
let base: Element<'_, Message> = container(content)
.width(Length::Fill)
.height(Length::Fill)
.padding(28)
.into();
let Some(artifact) = self.pending_model_delete else {
return base;
};
let confirmation = container(
column![
text("Delete model file?").size(22),
text(format!(
"Delete {artifact}, including any resumable partial download?"
))
.size(13),
row![
Space::with_width(Length::Fill),
action_button("Cancel").on_press(Message::CancelDeleteArtifact),
danger_button("Delete").on_press(Message::ConfirmDeleteArtifact),
]
.spacing(8),
]
.spacing(14),
)
.padding(22)
.width(460)
.style(overview_style);
stack![
base,
opaque(
container(confirmation)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| container::Style::default()
.background(Color::from_rgba8(0, 0, 0, 0.68)))
)
]
.into()
}
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
let dialog = container(
column![
@@ -402,12 +450,8 @@ impl App {
.padding(10),
row![
Space::with_width(Length::Fill),
button("Cancel")
.on_press(Message::CancelProject)
.style(button::secondary),
button("Add project")
.on_press(Message::ConfirmProject)
.style(button::primary),
action_button("Cancel").on_press(Message::CancelProject),
action_button("Add project").on_press(Message::ConfirmProject),
]
.spacing(8),
]
@@ -415,7 +459,7 @@ impl App {
)
.padding(22)
.width(440)
.style(container::rounded_box);
.style(overview_style);
opaque(
container(dialog)
@@ -441,9 +485,78 @@ impl App {
}
}
fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static, Message> {
let status = match artifact.state {
ManagedArtifactState::Missing => "Not downloaded",
ManagedArtifactState::Partial => "Partial download",
ManagedArtifactState::NeedsVerification => "Downloaded; verification required",
ManagedArtifactState::Ready => "Ready and verified",
};
let download_label = match artifact.state {
ManagedArtifactState::Ready => "Downloaded",
_ if artifact.stored > 0 => "Resume",
_ => "Download",
};
let download = if artifact.state == ManagedArtifactState::Ready || busy {
action_button(download_label)
} else {
action_button(download_label).on_press(Message::DownloadArtifact(artifact.id))
};
let validate = if artifact.can_validate() && !busy {
action_button("Validate").on_press(Message::ValidateArtifact(artifact.id))
} else {
action_button("Validate")
};
let delete = if artifact.stored > 0 && !busy {
danger_button("Delete").on_press(Message::DeleteArtifact(artifact.id))
} else {
danger_button("Delete")
};
container(
row![
icon(ICON_MODEL, 28),
column![
text(artifact.id.to_string()).size(16),
text(status).size(13).color(muted_text()),
text(format!(
"{} on disk • {} expected",
format_bytes(artifact.stored),
format_bytes(artifact.expected),
))
.size(12)
.color(muted_text()),
]
.spacing(5),
Space::with_width(Length::Fill),
row![download, validate, delete]
.spacing(8)
.align_y(Alignment::Center),
]
.spacing(16)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.padding([18, 20])
.into()
}
fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
let progress = &download.progress;
let percent = progress.fraction() * 100.0;
let measurement = if progress.verification.is_some() {
format!(
"{} verified of {} ({percent:.1}%)",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
)
} else {
format!(
"{} of {} ({percent:.1}%)",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
)
};
let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 {
format!(
"{}/s • about {} remaining",
@@ -454,11 +567,9 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
"Calculating time remaining…".to_owned()
};
let stop = if download.stopping {
button("Stopping…")
danger_button("Stopping…")
} else {
button("Stop")
.on_press(Message::StopModelDownload)
.style(button::danger)
danger_button("Stop").on_press(Message::StopModelDownload)
};
container(
@@ -467,12 +578,7 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
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(measurement).size(12),
text(transfer).size(12),
Space::with_width(Length::Fill),
stop,
@@ -486,60 +592,71 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
.into()
}
fn model_download_status<'a>(
download: &ModelDownload,
selected: &DownloadProgress,
) -> Element<'a, Message> {
fn model_download_status(download: &ModelDownload) -> Element<'_, 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::Idle => unreachable!("idle operations are not displayed"),
ModelDownload::Active(active) => (
&active.progress,
if active.stopping {
format!("Stopping {}", active.model)
format!("Stopping {}", active.artifact)
} else {
phase_text(active.progress.phase)
},
active.bytes_per_second,
false,
),
ModelDownload::Complete(model, progress) => (
ModelDownload::Complete(artifact, operation, progress) => (
progress,
format!("{model} is downloaded and verified."),
match operation {
ModelOperation::Download => format!("{artifact} is downloaded and verified."),
ModelOperation::Validate => format!("{artifact} passed validation."),
},
0.0,
false,
),
ModelDownload::Failed(model, error, progress) => (
ModelDownload::Failed(artifact, error, progress) => (
progress,
format!("{model} download failed: {error}"),
format!("{artifact} operation 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),
let measurement = if progress.verification.is_some() {
format!(
"{} verified of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
format_bytes(progress.remaining()),
))
.size(12),
)
} else {
format!(
"{} of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
format_bytes(progress.remaining()),
)
};
let heading = if failed {
text(heading).size(12).style(iced::widget::text::danger)
} else {
text(heading).size(12)
};
let mut heading_row = row![heading, Space::with_width(Length::Fill)]
.align_y(Alignment::Center)
.spacing(8);
if let ModelDownload::Active(active) = download {
heading_row = heading_row.push(if active.stopping {
danger_button("Stopping…")
} else {
danger_button("Stop").on_press(Message::StopModelDownload)
});
}
let mut status = column![
heading_row,
progress_bar(0.0..=1.0, progress.fraction()).height(8),
text(measurement).size(12),
]
.spacing(6);
if speed > 0.0 && progress.remaining() > 0 {
@@ -606,6 +723,58 @@ pub(crate) fn app_theme() -> Theme {
})
}
fn action_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Message> {
button(content).padding([8, 14]).style(action_button_style)
}
fn danger_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Message> {
button(content).padding([8, 14]).style(danger_button_style)
}
fn action_button_style(_: &Theme, status: button::Status) -> button::Style {
let (background, text_color) = match status {
button::Status::Active | button::Status::Pressed => {
(Color::from_rgb8(45, 45, 47), Color::WHITE)
}
button::Status::Hovered => (Color::from_rgb8(56, 56, 59), Color::WHITE),
button::Status::Disabled => (Color::from_rgb8(35, 35, 37), muted_text().scale_alpha(0.55)),
};
button::Style {
background: Some(Background::Color(background)),
text_color,
border: Border {
radius: 12.0.into(),
..Border::default()
},
..button::Style::default()
}
}
fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style {
let mut style = action_button_style(theme, status);
style.text_color = match status {
button::Status::Disabled => theme.palette().danger.scale_alpha(0.45),
_ => theme.palette().danger,
};
style
}
fn overview_style(_: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb8(31, 31, 33))),
border: Border {
color: Color::from_rgb8(61, 61, 64),
width: 1.0,
radius: 14.0.into(),
},
..container::Style::default()
}
}
fn muted_text() -> Color {
Color::from_rgb8(174, 174, 178)
}
fn sidebar_style(_: &Theme) -> container::Style {
container::Style::default().background(Color::from_rgb8(23, 23, 25))
}
@@ -631,6 +800,15 @@ mod tests {
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
}
#[test]
fn action_buttons_share_geometry() {
let theme = app_theme();
let action = action_button_style(&theme, button::Status::Active);
let danger = danger_button_style(&theme, button::Status::Active);
assert_eq!(action.background, danger.background);
assert_eq!(action.border, danger.border);
}
#[test]
fn download_measurements_are_readable() {
assert_eq!(format_bytes(1_500_000_000), "1.5 GB");