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

@@ -3,59 +3,24 @@ mod view;
pub(crate) use view::app_theme;
use crate::database::{AppPreferences, Database, ProjectWithSessions};
use crate::model::{self, DownloadOutcome, DownloadProgress, ModelChoice};
use iced::futures::{SinkExt, Stream};
use iced::{Subscription, Task, keyboard};
use rfd::AsyncFileDialog;
use std::fmt;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
const APP_ID: &str = "DS4Server.rfc1437.de";
const MODEL_CHOICES: [ModelChoice; 3] = [
ModelChoice::DeepSeekV4Flash,
ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52,
];
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ModelChoice {
#[default]
DeepSeekV4Flash,
DeepSeekV4Pro,
Glm52,
}
impl ModelChoice {
fn id(self) -> &'static str {
match self {
Self::DeepSeekV4Flash => "deepseek-v4-flash",
Self::DeepSeekV4Pro => "deepseek-v4-pro",
Self::Glm52 => "glm-5.2",
}
}
fn from_id(id: &str) -> Option<Self> {
MODEL_CHOICES.into_iter().find(|model| model.id() == id)
}
fn supports_dflash(self) -> bool {
self == Self::DeepSeekV4Flash
}
}
impl fmt::Display for ModelChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
Self::Glm52 => "GLM 5.2",
})
}
}
#[derive(Clone)]
struct PreferenceDraft {
model: ModelChoice,
dflash_enabled: bool,
dspark_enabled: bool,
idle_timeout_minutes: String,
}
@@ -65,7 +30,7 @@ impl PreferenceDraft {
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?;
Ok(Self {
model,
dflash_enabled: model.supports_dflash() && preferences.dflash_enabled,
dspark_enabled: model.supports_dspark() && preferences.dspark_enabled,
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
})
}
@@ -83,17 +48,42 @@ pub(crate) struct App {
choosing_folder: bool,
pending_project_path: Option<PathBuf>,
project_name_input: String,
model_download: ModelDownload,
error: Option<String>,
}
#[derive(Debug)]
pub(super) enum ModelDownload {
Idle,
Active(ActiveDownload),
Complete(ModelChoice, DownloadProgress),
Failed(ModelChoice, String, DownloadProgress),
}
#[derive(Debug)]
pub(super) struct ActiveDownload {
model: ModelChoice,
dspark_enabled: bool,
progress: DownloadProgress,
sampled_at: Instant,
sampled_bytes: u64,
bytes_per_second: f64,
cancel: Arc<AtomicBool>,
result: mpsc::Receiver<Result<DownloadOutcome, String>>,
stopping: bool,
}
#[derive(Debug, Clone)]
pub(crate) enum Message {
OpenPreferences,
DismissPanel,
PreferenceModelChanged(ModelChoice),
PreferenceDflashChanged(bool),
PreferenceDsparkChanged(bool),
PreferenceTimeoutChanged(String),
SavePreferences,
DownloadSelectedModel,
StopModelDownload,
DownloadProgressTick,
ChooseProjectFolder,
ProjectFolderPicked(Option<PathBuf>),
ProjectNameChanged(String),
@@ -128,6 +118,7 @@ impl App {
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
model_download: ModelDownload::Idle,
error: None,
}
}
@@ -153,6 +144,7 @@ impl App {
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
model_download: ModelDownload::Idle,
error: Some(format!("Could not open the project database: {error}")),
}
}
@@ -172,14 +164,14 @@ impl App {
}
Message::PreferenceModelChanged(model) => {
self.preference_draft.model = model;
if !model.supports_dflash() {
self.preference_draft.dflash_enabled = false;
if !model.supports_dspark() {
self.preference_draft.dspark_enabled = false;
}
self.preference_error = None;
}
Message::PreferenceDflashChanged(enabled) => {
self.preference_draft.dflash_enabled =
self.preference_draft.model.supports_dflash() && enabled;
Message::PreferenceDsparkChanged(enabled) => {
self.preference_draft.dspark_enabled =
self.preference_draft.model.supports_dspark() && enabled;
self.preference_error = None;
}
Message::PreferenceTimeoutChanged(value) => {
@@ -187,6 +179,48 @@ impl App {
self.preference_error = None;
}
Message::SavePreferences => self.save_preferences(),
Message::DownloadSelectedModel => {
if matches!(self.model_download, ModelDownload::Active(_)) {
return Task::none();
}
let model = self.preference_draft.model;
let dspark_enabled =
model.supports_dspark() && self.preference_draft.dspark_enabled;
let models_path = models_path();
let progress = model::download_progress(model, dspark_enabled, &models_path);
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
let (result_sender, result_receiver) = mpsc::channel();
if let Err(error) = thread::Builder::new()
.name("model-download".to_owned())
.spawn(move || {
let result =
model::download(model, dspark_enabled, &models_path, &worker_cancel);
let _ = result_sender.send(result);
})
{
self.error = Some(format!("Could not start model download: {error}"));
return Task::none();
}
self.model_download = ModelDownload::Active(ActiveDownload {
model,
dspark_enabled,
sampled_at: Instant::now(),
sampled_bytes: progress.downloaded,
progress,
bytes_per_second: 0.0,
cancel: Arc::clone(&cancel),
result: result_receiver,
stopping: false,
});
}
Message::StopModelDownload => {
if let ModelDownload::Active(download) = &mut self.model_download {
download.stopping = true;
download.cancel.store(true, Ordering::Relaxed);
}
}
Message::DownloadProgressTick => self.update_download_progress(),
Message::ChooseProjectFolder => {
self.choosing_folder = true;
return Task::perform(
@@ -256,7 +290,15 @@ impl App {
}
pub(crate) fn subscription(&self) -> Subscription<Message> {
keyboard::on_key_press(shortcut)
let shortcuts = keyboard::on_key_press(shortcut);
if matches!(self.model_download, ModelDownload::Active(_)) {
Subscription::batch([
shortcuts,
Subscription::run(download_ticks).map(|_| Message::DownloadProgressTick),
])
} else {
shortcuts
}
}
fn open_preferences(&mut self) {
@@ -289,11 +331,11 @@ impl App {
}
let model = self.preference_draft.model;
let dflash_enabled = model.supports_dflash() && self.preference_draft.dflash_enabled;
let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled;
let Some(database) = &mut self.database else {
return;
};
match database.update_preferences(model.id(), dflash_enabled, idle_timeout_minutes) {
match database.update_preferences(model.id(), dspark_enabled, idle_timeout_minutes) {
Ok(preferences) => {
self.preferences = preferences;
self.preference_draft = PreferenceDraft::from_saved(&self.preferences)
@@ -395,6 +437,73 @@ impl App {
}
}
}
fn update_download_progress(&mut self) {
let ModelDownload::Active(download) = &mut self.model_download else {
return;
};
let now = Instant::now();
let progress =
model::download_progress(download.model, download.dspark_enabled, &models_path());
let elapsed = now.duration_since(download.sampled_at).as_secs_f64();
let transferred = progress.downloaded.saturating_sub(download.sampled_bytes);
if transferred > 0 && elapsed > 0.0 {
let current = transferred as f64 / elapsed;
download.bytes_per_second = if download.bytes_per_second == 0.0 {
current
} else {
download.bytes_per_second * 0.75 + current * 0.25
};
}
download.progress = progress;
download.sampled_at = now;
download.sampled_bytes = download.progress.downloaded;
let result = match download.result.try_recv() {
Ok(result) => Some(result),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
Some(Err("Download worker stopped unexpectedly.".into()))
}
};
let model = download.model;
let progress = download.progress.clone();
if let Some(result) = result {
match result {
Ok(DownloadOutcome::Complete) => {
self.model_download = ModelDownload::Complete(model, progress);
self.error = None;
}
Ok(DownloadOutcome::Stopped) => {
self.model_download = ModelDownload::Idle;
self.error = None;
}
Err(error) => {
self.error = Some(error.clone());
self.model_download = ModelDownload::Failed(model, error, progress);
}
}
}
}
}
impl Drop for App {
fn drop(&mut self) {
if let ModelDownload::Active(download) = &self.model_download {
download.cancel.store(true, Ordering::Relaxed);
}
}
}
fn download_ticks() -> impl Stream<Item = ()> {
iced::stream::channel(1, |mut output| async move {
loop {
std::thread::sleep(Duration::from_secs(1));
if output.send(()).await.is_err() {
break;
}
}
})
}
fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> {
@@ -414,19 +523,23 @@ fn application_support_path() -> PathBuf {
.join(APP_ID)
}
fn models_path() -> PathBuf {
application_support_path().join("models")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preferences_shortcut_and_dflash_support_are_explicit() {
fn preferences_shortcut_and_dspark_support_are_explicit() {
let message = shortcut(
keyboard::Key::Character(",".into()),
keyboard::Modifiers::COMMAND,
);
assert!(matches!(message, Some(Message::OpenPreferences)));
assert!(ModelChoice::DeepSeekV4Flash.supports_dflash());
assert!(!ModelChoice::DeepSeekV4Pro.supports_dflash());
assert!(!ModelChoice::Glm52.supports_dflash());
assert!(ModelChoice::DeepSeekV4Flash.supports_dspark());
assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark());
assert!(!ModelChoice::Glm52.supports_dspark());
}
}

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");
}
}

View File

@@ -14,7 +14,7 @@ pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
pub struct AppPreferences {
pub id: i32,
pub selected_model: String,
pub dflash_enabled: bool,
pub dspark_enabled: bool,
pub idle_timeout_minutes: i32,
}
@@ -23,7 +23,7 @@ impl Default for AppPreferences {
Self {
id: 1,
selected_model: "deepseek-v4-flash".into(),
dflash_enabled: false,
dspark_enabled: false,
idle_timeout_minutes: 10,
}
}
@@ -33,7 +33,7 @@ impl Default for AppPreferences {
#[diesel(table_name = preferences)]
struct PreferenceChanges<'a> {
selected_model: &'a str,
dflash_enabled: bool,
dspark_enabled: bool,
idle_timeout_minutes: i32,
}
@@ -135,13 +135,13 @@ impl Database {
pub fn update_preferences(
&mut self,
selected_model: &str,
dflash_enabled: bool,
dspark_enabled: bool,
idle_timeout_minutes: i32,
) -> Result<AppPreferences, String> {
diesel::update(preferences::table.find(1))
.set(PreferenceChanges {
selected_model,
dflash_enabled,
dspark_enabled,
idle_timeout_minutes,
})
.returning(AppPreferences::as_returning())
@@ -200,7 +200,7 @@ mod tests {
let preferences = database.load_preferences().unwrap();
assert_eq!(preferences.selected_model, "deepseek-v4-flash");
assert!(!preferences.dflash_enabled);
assert!(!preferences.dspark_enabled);
assert_eq!(preferences.idle_timeout_minutes, 10);
assert!(database.update_preferences("glm-5.2", true, 30).is_err());
assert!(

View File

@@ -1,5 +1,6 @@
mod app;
mod database;
mod model;
mod schema;
use app::{App, app_theme};

567
src/model.rs Normal file
View File

@@ -0,0 +1,567 @@
use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use sha2::{Digest, Sha256};
pub(crate) const MODEL_CHOICES: [ModelChoice; 3] = [
ModelChoice::DeepSeekV4Flash,
ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52,
];
const DEEPSEEK_REPOSITORY: &str = "antirez/deepseek-v4-gguf";
const GLM_REPOSITORY: &str = "antirez/glm-5.2-gguf";
const FLASH: Artifact = Artifact {
label: "DeepSeek V4 Flash model",
file_name: "DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf",
repository: DEEPSEEK_REPOSITORY,
size: 86_720_111_488,
sha256: "efc7ed607ff27076e3e501fc3fefefa33c0ed8cf1eff483a2b7fdc0c2e616668",
};
const FLASH_DSPARK: Artifact = Artifact {
label: "DSpark support",
file_name: "DeepSeek-V4-Flash-DSpark-support.gguf",
repository: DEEPSEEK_REPOSITORY,
size: 5_989_114_272,
sha256: "8b3adf5942bec22ae2ea867cd7079cf13530ba83ffcffaf00f5de48664a1a34e",
};
const PRO: Artifact = Artifact {
label: "DeepSeek V4 Pro model",
file_name: "DeepSeek-V4-Pro-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-Instruct-imatrix.gguf",
repository: DEEPSEEK_REPOSITORY,
size: 464_627_334_560,
sha256: "a0314d9c0e16122cd60071079124a2d17185d317c55a8f95ecb3ed3506278a96",
};
const GLM: Artifact = Artifact {
label: "GLM 5.2 model",
file_name: "GLM-5.2-UD-IQ2_XXS_RoutedIQ2XXS_blk78Q2K.gguf",
repository: GLM_REPOSITORY,
size: 211_075_856_448,
sha256: "a49de64c5020432bdae23de36a423a9660a5621bc0db8d12b66bd8814b07fea0",
};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ModelChoice {
#[default]
DeepSeekV4Flash,
DeepSeekV4Pro,
Glm52,
}
impl ModelChoice {
pub(crate) fn id(self) -> &'static str {
match self {
Self::DeepSeekV4Flash => "deepseek-v4-flash",
Self::DeepSeekV4Pro => "deepseek-v4-pro",
Self::Glm52 => "glm-5.2",
}
}
pub(crate) fn from_id(id: &str) -> Option<Self> {
MODEL_CHOICES.into_iter().find(|model| model.id() == id)
}
pub(crate) fn supports_dspark(self) -> bool {
self == Self::DeepSeekV4Flash
}
fn main_artifact(self) -> &'static Artifact {
match self {
Self::DeepSeekV4Flash => &FLASH,
Self::DeepSeekV4Pro => &PRO,
Self::Glm52 => &GLM,
}
}
fn artifacts(self, dspark_enabled: bool) -> impl Iterator<Item = &'static Artifact> {
[
Some(self.main_artifact()),
(self.supports_dspark() && dspark_enabled).then_some(&FLASH_DSPARK),
]
.into_iter()
.flatten()
}
pub(crate) fn download_size(self, dspark_enabled: bool) -> u64 {
self.artifacts(dspark_enabled)
.map(|artifact| artifact.size)
.sum()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DownloadPhase {
Pending(&'static str),
Downloading(&'static str),
Verifying(&'static str),
Complete,
}
#[derive(Clone, Debug)]
pub(crate) struct DownloadProgress {
pub(crate) downloaded: u64,
pub(crate) total: u64,
pub(crate) phase: DownloadPhase,
}
impl DownloadProgress {
pub(crate) fn remaining(&self) -> u64 {
self.total.saturating_sub(self.downloaded)
}
pub(crate) fn fraction(&self) -> f32 {
if self.total == 0 {
0.0
} else {
self.downloaded as f32 / self.total as f32
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DownloadOutcome {
Complete,
Stopped,
}
impl fmt::Display for ModelChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
Self::Glm52 => "GLM 5.2",
})
}
}
struct Artifact {
label: &'static str,
file_name: &'static str,
repository: &'static str,
size: u64,
sha256: &'static str,
}
impl Artifact {
fn path(&self, model: ModelChoice, models_path: &Path) -> PathBuf {
models_path.join(model.id()).join(self.file_name)
}
fn url(&self) -> String {
format!(
"https://huggingface.co/{}/resolve/main/{}",
self.repository, self.file_name
)
}
fn verification_path(&self, model: ModelChoice, models_path: &Path) -> PathBuf {
self.path(model, models_path).with_extension("gguf.sha256")
}
fn partial_path(&self, model: ModelChoice, models_path: &Path) -> PathBuf {
self.path(model, models_path).with_extension("gguf.part")
}
fn is_installed(&self, model: ModelChoice, models_path: &Path) -> bool {
self.path(model, models_path)
.metadata()
.is_ok_and(|metadata| metadata.len() == self.size)
&& fs::read_to_string(self.verification_path(model, models_path))
.is_ok_and(|sha256| sha256.trim() == self.sha256)
}
fn downloaded_bytes(&self, model: ModelChoice, models_path: &Path) -> u64 {
if self.is_installed(model, models_path) {
return self.size;
}
self.path(model, models_path)
.metadata()
.or_else(|_| self.partial_path(model, models_path).metadata())
.map_or(0, |metadata| metadata.len().min(self.size))
}
}
pub(crate) fn download_progress(
model: ModelChoice,
dspark_enabled: bool,
models_path: &Path,
) -> DownloadProgress {
let mut downloaded = 0;
let mut phase = DownloadPhase::Complete;
for artifact in model.artifacts(dspark_enabled) {
let bytes = artifact.downloaded_bytes(model, models_path);
downloaded += bytes;
if phase == DownloadPhase::Complete && !artifact.is_installed(model, models_path) {
phase = if bytes >= artifact.size {
DownloadPhase::Verifying(artifact.label)
} else if bytes > 0 {
DownloadPhase::Downloading(artifact.label)
} else {
DownloadPhase::Pending(artifact.label)
};
}
}
DownloadProgress {
downloaded,
total: model.download_size(dspark_enabled),
phase,
}
}
pub(crate) fn download(
model: ModelChoice,
dspark_enabled: bool,
models_path: &Path,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
for artifact in model.artifacts(dspark_enabled) {
if download_artifact_with_cancel(model, artifact, models_path, cancel)?
== DownloadOutcome::Stopped
{
return Ok(DownloadOutcome::Stopped);
}
}
Ok(DownloadOutcome::Complete)
}
#[cfg(test)]
fn download_artifact(
model: ModelChoice,
artifact: &Artifact,
models_path: &Path,
) -> Result<DownloadOutcome, String> {
download_artifact_with_cancel(model, artifact, models_path, &AtomicBool::new(false))
}
fn download_artifact_with_cancel(
model: ModelChoice,
artifact: &Artifact,
models_path: &Path,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
let destination = artifact.path(model, models_path);
if artifact.is_installed(model, models_path) {
return Ok(DownloadOutcome::Complete);
}
if destination.exists() {
if verify(&destination, artifact, cancel)? == DownloadOutcome::Stopped {
return Ok(DownloadOutcome::Stopped);
}
mark_verified(model, artifact, models_path)?;
return Ok(DownloadOutcome::Complete);
}
let directory = destination
.parent()
.ok_or_else(|| "model artifact path has no parent directory".to_owned())?;
fs::create_dir_all(directory).map_err(|error| error.to_string())?;
let partial = artifact.partial_path(model, models_path);
let partial_size = partial.metadata().map_or(0, |metadata| metadata.len());
if partial_size > artifact.size {
File::create(&partial).map_err(|error| error.to_string())?;
}
if partial.metadata().map_or(0, |metadata| metadata.len()) != artifact.size
&& download_to_partial(artifact, &partial, cancel)? == DownloadOutcome::Stopped
{
return Ok(DownloadOutcome::Stopped);
}
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
match verify(&partial, artifact, cancel) {
Ok(DownloadOutcome::Stopped) => return Ok(DownloadOutcome::Stopped),
Ok(DownloadOutcome::Complete) => {}
Err(error) => {
fs::remove_file(&partial).map_err(|remove_error| {
format!("{error}; could not remove partial file: {remove_error}")
})?;
return Err(error);
}
}
fs::rename(partial, destination).map_err(|error| error.to_string())?;
mark_verified(model, artifact, models_path)?;
Ok(DownloadOutcome::Complete)
}
fn mark_verified(
model: ModelChoice,
artifact: &Artifact,
models_path: &Path,
) -> Result<(), String> {
fs::write(
artifact.verification_path(model, models_path),
artifact.sha256,
)
.map_err(|error| error.to_string())
}
fn verify(
path: &Path,
artifact: &Artifact,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
let size = path.metadata().map_err(|error| error.to_string())?.len();
if size != artifact.size {
return Err(format!(
"{} has size {size}, expected {}",
path.display(),
artifact.size
));
}
let mut file = File::open(path).map_err(|error| error.to_string())?;
let mut hasher = Sha256::new();
let mut buffer = vec![0; 1024 * 1024];
loop {
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
let count = file.read(&mut buffer).map_err(|error| error.to_string())?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
let actual = hex(&hasher.finalize());
if actual != artifact.sha256 {
return Err(format!(
"Checksum verification failed for {}",
path.display()
));
}
Ok(DownloadOutcome::Complete)
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut result = String::with_capacity(bytes.len() * 2);
for byte in bytes {
result.push(DIGITS[(byte >> 4) as usize] as char);
result.push(DIGITS[(byte & 0x0f) as usize] as char);
}
result
}
fn download_to_partial(
artifact: &Artifact,
partial: &Path,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
download_url_to_partial(&artifact.url(), partial, cancel)
}
fn download_url_to_partial(
url: &str,
partial: &Path,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
let offset = partial.metadata().map_or(0, |metadata| metadata.len());
let agent: ureq::Agent = ureq::Agent::config_builder()
.https_only(url.starts_with("https://"))
.build()
.into();
let mut request = agent.get(url);
if offset > 0 {
request = request.header("Range", format!("bytes={offset}-"));
}
let mut response = request
.call()
.map_err(|error| format!("Model download failed: {error}"))?;
let status = response.status().as_u16();
let append = offset > 0 && status == 206;
if offset > 0 && status != 200 && status != 206 {
return Err(format!(
"Model server returned HTTP {status} while resuming at byte {offset}"
));
}
if append {
validate_content_range(&response, offset)?;
}
let mut output = OpenOptions::new()
.create(true)
.write(true)
.append(append)
.truncate(!append)
.open(partial)
.map_err(|error| error.to_string())?;
let mut body = response.body_mut().as_reader();
let mut buffer = vec![0; 1024 * 1024];
loop {
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
let count = body
.read(&mut buffer)
.map_err(|error| format!("Model download failed: {error}"))?;
if count == 0 {
break;
}
output
.write_all(&buffer[..count])
.map_err(|error| error.to_string())?;
}
Ok(DownloadOutcome::Complete)
}
fn validate_content_range(
response: &ureq::http::Response<ureq::Body>,
offset: u64,
) -> Result<(), String> {
let expected = format!("bytes {offset}-");
let content_range = response
.headers()
.get("content-range")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if content_range.starts_with(&expected) {
Ok(())
} else {
Err(format!(
"Model server returned an invalid Content-Range while resuming at byte {offset}"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn catalog_and_checksum_verification_are_explicit() {
assert_eq!(ModelChoice::from_id("glm-5.2"), Some(ModelChoice::Glm52));
assert!(ModelChoice::from_id("unknown").is_none());
assert_eq!(
ModelChoice::DeepSeekV4Flash.main_artifact().size,
86_720_111_488
);
assert_eq!(ModelChoice::Glm52.main_artifact().size, 211_075_856_448);
assert_eq!(ModelChoice::DeepSeekV4Flash.artifacts(true).count(), 2);
assert_eq!(ModelChoice::Glm52.artifacts(true).count(), 1);
let id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let models_path = std::env::temp_dir().join(format!("ds4-server-models-{id}"));
let empty = Artifact {
label: "empty",
file_name: "empty",
repository: "",
size: 0,
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
};
let partial = empty.partial_path(ModelChoice::DeepSeekV4Flash, &models_path);
fs::create_dir_all(partial.parent().unwrap()).unwrap();
fs::write(&partial, []).unwrap();
download_artifact(ModelChoice::DeepSeekV4Flash, &empty, &models_path).unwrap();
assert!(empty.is_installed(ModelChoice::DeepSeekV4Flash, &models_path));
assert!(!partial.exists());
assert_eq!(
fs::read_to_string(empty.verification_path(ModelChoice::DeepSeekV4Flash, &models_path))
.unwrap(),
empty.sha256
);
fs::remove_dir_all(models_path).unwrap();
}
#[test]
fn restart_resumes_at_the_existing_partial_byte() {
let content = b"restart-resume works";
let offset = 8;
let directory = std::env::temp_dir().join(format!(
"ds4-server-resume-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&directory).unwrap();
let partial = directory.join("model.gguf.part");
fs::write(&partial, &content[..offset]).unwrap();
let listener = match TcpListener::bind("127.0.0.1:0") {
Ok(listener) => listener,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
fs::remove_dir_all(directory).unwrap();
return;
}
Err(error) => panic!("could not start test server: {error}"),
};
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut connection, _) = listener.accept().unwrap();
let mut request = [0; 2048];
let count = connection.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..count]).to_ascii_lowercase();
assert!(request.contains("range: bytes=8-"));
let remaining = &content[offset..];
write!(
connection,
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {offset}-{}/{}\r\nConnection: close\r\n\r\n",
remaining.len(),
content.len() - 1,
content.len(),
)
.unwrap();
connection.write_all(remaining).unwrap();
});
let outcome = download_url_to_partial(
&format!("http://{address}/model.gguf"),
&partial,
&AtomicBool::new(false),
)
.unwrap();
server.join().unwrap();
assert_eq!(outcome, DownloadOutcome::Complete);
assert_eq!(fs::read(&partial).unwrap(), content);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn cancellation_keeps_the_partial_file_for_the_next_run() {
let directory = std::env::temp_dir().join(format!(
"ds4-server-cancel-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let artifact = Artifact {
label: "test model",
file_name: "model.gguf",
repository: "unused",
size: 10,
sha256: "unused",
};
let partial = artifact.partial_path(ModelChoice::DeepSeekV4Flash, &directory);
fs::create_dir_all(partial.parent().unwrap()).unwrap();
fs::write(&partial, b"part").unwrap();
let cancel = AtomicBool::new(true);
assert_eq!(
download_artifact_with_cancel(
ModelChoice::DeepSeekV4Flash,
&artifact,
&directory,
&cancel,
)
.unwrap(),
DownloadOutcome::Stopped
);
assert_eq!(fs::read(&partial).unwrap(), b"part");
fs::remove_dir_all(directory).unwrap();
}
}

View File

@@ -10,7 +10,7 @@ diesel::table! {
preferences (id) {
id -> Integer,
selected_model -> Text,
dflash_enabled -> Bool,
dspark_enabled -> Bool,
idle_timeout_minutes -> Integer,
}
}