Mayo commited on
feat: download progress indicator
Browse files- Cargo.lock +6 -1
- koharu-core/Cargo.toml +7 -3
- koharu-core/src/download.rs +191 -0
- koharu-core/src/hf_hub.rs +107 -0
- koharu-core/src/http.rs +3 -92
- koharu-core/src/lib.rs +2 -0
- koharu-ml/Cargo.toml +3 -2
- koharu-ml/src/hf_hub.rs +5 -56
- koharu-ml/src/lib.rs +1 -1
- koharu-runtime/src/dylib.rs +3 -2
- koharu/Cargo.toml +2 -0
- koharu/src/app.rs +4 -1
- koharu/src/command.rs +22 -6
- koharu/src/endpoints.rs +21 -1
- koharu/src/server.rs +1 -0
- koharu/tauri.conf.json +2 -2
- ui/app/splashscreen/page.tsx +94 -0
- ui/components/ActivityBubble.tsx +74 -20
- ui/lib/backend.ts +39 -0
- ui/lib/downloads.ts +52 -0
- ui/public/locales/en-US/translation.json +3 -0
- ui/public/locales/ja-JP/translation.json +3 -0
- ui/public/locales/zh-CN/translation.json +3 -0
- ui/public/locales/zh-TW/translation.json +3 -0
Cargo.lock
CHANGED
|
@@ -4063,9 +4063,11 @@ dependencies = [
|
|
| 4063 |
"blake3",
|
| 4064 |
"clap",
|
| 4065 |
"dirs",
|
|
|
|
| 4066 |
"git-version",
|
| 4067 |
"icu",
|
| 4068 |
"image",
|
|
|
|
| 4069 |
"koharu-ml",
|
| 4070 |
"koharu-renderer",
|
| 4071 |
"koharu-runtime",
|
|
@@ -4097,12 +4099,16 @@ name = "koharu-core"
|
|
| 4097 |
version = "0.29.0"
|
| 4098 |
dependencies = [
|
| 4099 |
"anyhow",
|
|
|
|
| 4100 |
"futures",
|
|
|
|
| 4101 |
"indicatif",
|
| 4102 |
"once_cell",
|
| 4103 |
"reqwest",
|
| 4104 |
"reqwest-middleware",
|
| 4105 |
"reqwest-retry",
|
|
|
|
|
|
|
| 4106 |
"tracing",
|
| 4107 |
]
|
| 4108 |
|
|
@@ -4118,7 +4124,6 @@ dependencies = [
|
|
| 4118 |
"cudarc 0.19.0",
|
| 4119 |
"dirs",
|
| 4120 |
"futures",
|
| 4121 |
-
"hf-hub",
|
| 4122 |
"image",
|
| 4123 |
"imageproc",
|
| 4124 |
"koharu-core",
|
|
|
|
| 4063 |
"blake3",
|
| 4064 |
"clap",
|
| 4065 |
"dirs",
|
| 4066 |
+
"futures",
|
| 4067 |
"git-version",
|
| 4068 |
"icu",
|
| 4069 |
"image",
|
| 4070 |
+
"koharu-core",
|
| 4071 |
"koharu-ml",
|
| 4072 |
"koharu-renderer",
|
| 4073 |
"koharu-runtime",
|
|
|
|
| 4099 |
version = "0.29.0"
|
| 4100 |
dependencies = [
|
| 4101 |
"anyhow",
|
| 4102 |
+
"dirs",
|
| 4103 |
"futures",
|
| 4104 |
+
"hf-hub",
|
| 4105 |
"indicatif",
|
| 4106 |
"once_cell",
|
| 4107 |
"reqwest",
|
| 4108 |
"reqwest-middleware",
|
| 4109 |
"reqwest-retry",
|
| 4110 |
+
"serde",
|
| 4111 |
+
"tokio",
|
| 4112 |
"tracing",
|
| 4113 |
]
|
| 4114 |
|
|
|
|
| 4124 |
"cudarc 0.19.0",
|
| 4125 |
"dirs",
|
| 4126 |
"futures",
|
|
|
|
| 4127 |
"image",
|
| 4128 |
"imageproc",
|
| 4129 |
"koharu-core",
|
koharu-core/Cargo.toml
CHANGED
|
@@ -13,10 +13,14 @@ publish.workspace = true
|
|
| 13 |
|
| 14 |
[dependencies]
|
| 15 |
anyhow = { workspace = true }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
reqwest = { workspace = true }
|
| 17 |
reqwest-middleware = { workspace = true }
|
| 18 |
reqwest-retry = { workspace = true }
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
indicatif = { workspace = true }
|
| 22 |
tracing = { workspace = true }
|
|
|
|
| 13 |
|
| 14 |
[dependencies]
|
| 15 |
anyhow = { workspace = true }
|
| 16 |
+
dirs = { workspace = true }
|
| 17 |
+
futures = { workspace = true }
|
| 18 |
+
hf-hub = { workspace = true }
|
| 19 |
+
indicatif = { workspace = true }
|
| 20 |
+
once_cell = { workspace = true }
|
| 21 |
reqwest = { workspace = true }
|
| 22 |
reqwest-middleware = { workspace = true }
|
| 23 |
reqwest-retry = { workspace = true }
|
| 24 |
+
serde = { workspace = true }
|
| 25 |
+
tokio = { workspace = true }
|
|
|
|
| 26 |
tracing = { workspace = true }
|
koharu-core/src/download.rs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::PathBuf;
|
| 2 |
+
use std::sync::Arc;
|
| 3 |
+
|
| 4 |
+
use anyhow::Context;
|
| 5 |
+
use futures::{StreamExt, TryStreamExt, stream};
|
| 6 |
+
use once_cell::sync::Lazy;
|
| 7 |
+
use reqwest::header::{ACCEPT_RANGES, CONTENT_LENGTH};
|
| 8 |
+
use serde::Serialize;
|
| 9 |
+
use tokio::sync::broadcast;
|
| 10 |
+
use tracing::Instrument;
|
| 11 |
+
|
| 12 |
+
use crate::hf_hub;
|
| 13 |
+
use crate::http::{http_chunk, http_client};
|
| 14 |
+
use crate::progress::progress_bar;
|
| 15 |
+
|
| 16 |
+
const RANGE_CHUNK_SIZE_BYTES: usize = 16 * 1024 * 1024;
|
| 17 |
+
|
| 18 |
+
#[derive(Clone, Debug, Serialize)]
|
| 19 |
+
pub struct DownloadProgress {
|
| 20 |
+
pub filename: String,
|
| 21 |
+
pub downloaded: u64,
|
| 22 |
+
pub total: Option<u64>,
|
| 23 |
+
pub status: Status,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#[derive(Clone, Debug, Serialize, PartialEq)]
|
| 27 |
+
pub enum Status {
|
| 28 |
+
Started,
|
| 29 |
+
Downloading,
|
| 30 |
+
Completed,
|
| 31 |
+
Failed(String),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
static TX: Lazy<broadcast::Sender<DownloadProgress>> = Lazy::new(|| broadcast::channel(256).0);
|
| 35 |
+
|
| 36 |
+
pub fn subscribe() -> broadcast::Receiver<DownloadProgress> {
|
| 37 |
+
TX.subscribe()
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
pub(crate) fn emit(progress: DownloadProgress) {
|
| 41 |
+
let _ = TX.send(progress);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
pub async fn model(repo: &str, filename: &str) -> anyhow::Result<PathBuf> {
|
| 45 |
+
let hf_repo = hf_hub::repo(repo);
|
| 46 |
+
|
| 47 |
+
if let Some(path) = hf_hub::cache().repo(hf_repo.clone()).get(filename) {
|
| 48 |
+
return Ok(path);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
let reporter = hf_hub::Reporter::new(filename);
|
| 52 |
+
|
| 53 |
+
let path = hf_hub::api()
|
| 54 |
+
.repo(hf_repo)
|
| 55 |
+
.download_with_progress(filename, reporter)
|
| 56 |
+
.instrument(tracing::info_span!("hf_download", repo, filename))
|
| 57 |
+
.await
|
| 58 |
+
.context("failed to download from HF Hub")?;
|
| 59 |
+
|
| 60 |
+
Ok(path)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
#[tracing::instrument(level = "info")]
|
| 64 |
+
pub async fn bytes(url: &str) -> anyhow::Result<Vec<u8>> {
|
| 65 |
+
let client = http_client();
|
| 66 |
+
|
| 67 |
+
let head = client
|
| 68 |
+
.head(url)
|
| 69 |
+
.send()
|
| 70 |
+
.await?
|
| 71 |
+
.error_for_status()
|
| 72 |
+
.context(format!("cannot download {url}"))?;
|
| 73 |
+
|
| 74 |
+
let headers = head.headers();
|
| 75 |
+
let total_bytes = headers
|
| 76 |
+
.get(CONTENT_LENGTH)
|
| 77 |
+
.and_then(|v| v.to_str().ok())
|
| 78 |
+
.and_then(|v| v.parse::<u64>().ok())
|
| 79 |
+
.context("missing Content-Length header")?;
|
| 80 |
+
|
| 81 |
+
anyhow::ensure!(total_bytes > 0, "resource reports zero Content-Length");
|
| 82 |
+
|
| 83 |
+
let supports_ranges = headers
|
| 84 |
+
.get(ACCEPT_RANGES)
|
| 85 |
+
.and_then(|v| v.to_str().ok())
|
| 86 |
+
.map(|v| v.eq_ignore_ascii_case("bytes"))
|
| 87 |
+
.unwrap_or(false);
|
| 88 |
+
|
| 89 |
+
anyhow::ensure!(
|
| 90 |
+
supports_ranges,
|
| 91 |
+
"remote server does not advertise byte ranges"
|
| 92 |
+
);
|
| 93 |
+
|
| 94 |
+
let filename = url.split('/').next_back().unwrap_or(url).to_string();
|
| 95 |
+
let pb = Arc::new(progress_bar(&filename));
|
| 96 |
+
|
| 97 |
+
let total_len =
|
| 98 |
+
usize::try_from(total_bytes).context("resource too large to fit into memory")?;
|
| 99 |
+
let chunk_size = total_len.clamp(1, RANGE_CHUNK_SIZE_BYTES);
|
| 100 |
+
let segments = total_len.div_ceil(chunk_size);
|
| 101 |
+
|
| 102 |
+
pb.set_length(total_len as u64);
|
| 103 |
+
|
| 104 |
+
emit(DownloadProgress {
|
| 105 |
+
filename: filename.clone(),
|
| 106 |
+
downloaded: 0,
|
| 107 |
+
total: Some(total_bytes),
|
| 108 |
+
status: Status::Started,
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
tracing::debug!(
|
| 112 |
+
%url,
|
| 113 |
+
total_bytes,
|
| 114 |
+
segments,
|
| 115 |
+
"downloading resource via HTTP range requests"
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
let downloaded = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
| 119 |
+
|
| 120 |
+
let chunks = {
|
| 121 |
+
let url = Arc::new(url.to_string());
|
| 122 |
+
let pb = Arc::clone(&pb);
|
| 123 |
+
let downloaded = Arc::clone(&downloaded);
|
| 124 |
+
let filename = filename.clone();
|
| 125 |
+
stream::iter((0..segments).map(move |index| {
|
| 126 |
+
let start = (index * chunk_size) as u64;
|
| 127 |
+
let len = ((index + 1) * chunk_size).min(total_len) - (index * chunk_size);
|
| 128 |
+
let end = start + len as u64 - 1;
|
| 129 |
+
let url = Arc::clone(&url);
|
| 130 |
+
let pb = Arc::clone(&pb);
|
| 131 |
+
let downloaded = Arc::clone(&downloaded);
|
| 132 |
+
let filename = filename.clone();
|
| 133 |
+
async move {
|
| 134 |
+
let chunk = http_chunk(&url, start, end).await?;
|
| 135 |
+
pb.inc(len as u64);
|
| 136 |
+
let current = downloaded
|
| 137 |
+
.fetch_add(len as u64, std::sync::atomic::Ordering::Relaxed)
|
| 138 |
+
+ len as u64;
|
| 139 |
+
emit(DownloadProgress {
|
| 140 |
+
filename,
|
| 141 |
+
downloaded: current,
|
| 142 |
+
total: Some(total_bytes),
|
| 143 |
+
status: Status::Downloading,
|
| 144 |
+
});
|
| 145 |
+
Ok::<_, anyhow::Error>((start, chunk))
|
| 146 |
+
}
|
| 147 |
+
}))
|
| 148 |
+
.buffer_unordered(segments)
|
| 149 |
+
.try_collect::<Vec<_>>()
|
| 150 |
+
.await
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
let chunks = match chunks {
|
| 154 |
+
Ok(c) => c,
|
| 155 |
+
Err(e) => {
|
| 156 |
+
emit(DownloadProgress {
|
| 157 |
+
filename,
|
| 158 |
+
downloaded: downloaded.load(std::sync::atomic::Ordering::Relaxed),
|
| 159 |
+
total: Some(total_bytes),
|
| 160 |
+
status: Status::Failed(e.to_string()),
|
| 161 |
+
});
|
| 162 |
+
return Err(e);
|
| 163 |
+
}
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
pb.finish_and_clear();
|
| 167 |
+
|
| 168 |
+
let mut parts = chunks;
|
| 169 |
+
parts.sort_by_key(|(start, _)| *start);
|
| 170 |
+
|
| 171 |
+
let mut buffer = Vec::with_capacity(total_len);
|
| 172 |
+
for (_start, mut chunk) in parts {
|
| 173 |
+
buffer.append(&mut chunk);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
anyhow::ensure!(
|
| 177 |
+
buffer.len() == total_len,
|
| 178 |
+
"range assembly mismatch: expected {} bytes, got {}",
|
| 179 |
+
total_len,
|
| 180 |
+
buffer.len()
|
| 181 |
+
);
|
| 182 |
+
|
| 183 |
+
emit(DownloadProgress {
|
| 184 |
+
filename,
|
| 185 |
+
downloaded: total_bytes,
|
| 186 |
+
total: Some(total_bytes),
|
| 187 |
+
status: Status::Completed,
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
Ok(buffer)
|
| 191 |
+
}
|
koharu-core/src/hf_hub.rs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::PathBuf;
|
| 2 |
+
use std::sync::Arc;
|
| 3 |
+
use std::sync::atomic::{AtomicU64, Ordering};
|
| 4 |
+
|
| 5 |
+
use hf_hub::{
|
| 6 |
+
Cache, Repo,
|
| 7 |
+
api::tokio::{Api, ApiBuilder, Progress},
|
| 8 |
+
};
|
| 9 |
+
use indicatif::ProgressBar;
|
| 10 |
+
use once_cell::sync::{Lazy, OnceCell};
|
| 11 |
+
|
| 12 |
+
use crate::download::{DownloadProgress, Status, emit};
|
| 13 |
+
use crate::progress::progress_bar;
|
| 14 |
+
|
| 15 |
+
static CACHE_DIR: OnceCell<PathBuf> = OnceCell::new();
|
| 16 |
+
|
| 17 |
+
static HF_API: Lazy<Api> = Lazy::new(|| {
|
| 18 |
+
ApiBuilder::new()
|
| 19 |
+
.with_cache_dir(get_cache_dir().to_path_buf())
|
| 20 |
+
.high()
|
| 21 |
+
.build()
|
| 22 |
+
.expect("build HF API client")
|
| 23 |
+
});
|
| 24 |
+
static HF_CACHE: Lazy<Cache> = Lazy::new(|| Cache::new(get_cache_dir().to_path_buf()));
|
| 25 |
+
|
| 26 |
+
fn get_cache_dir() -> &'static PathBuf {
|
| 27 |
+
CACHE_DIR.get_or_init(|| {
|
| 28 |
+
dirs::cache_dir()
|
| 29 |
+
.unwrap_or_default()
|
| 30 |
+
.join("Koharu")
|
| 31 |
+
.join("models")
|
| 32 |
+
})
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
pub fn set_cache_dir(path: PathBuf) -> anyhow::Result<()> {
|
| 36 |
+
CACHE_DIR
|
| 37 |
+
.set(path)
|
| 38 |
+
.map_err(|_| anyhow::anyhow!("cache dir has already been set"))
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
pub fn api() -> &'static Api {
|
| 42 |
+
&HF_API
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
pub fn cache() -> &'static Cache {
|
| 46 |
+
&HF_CACHE
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
pub fn repo(name: &str) -> Repo {
|
| 50 |
+
Repo::model(name.to_string())
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
#[derive(Clone)]
|
| 54 |
+
pub(crate) struct Reporter {
|
| 55 |
+
pb: ProgressBar,
|
| 56 |
+
filename: String,
|
| 57 |
+
downloaded: Arc<AtomicU64>,
|
| 58 |
+
total: u64,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
impl Reporter {
|
| 62 |
+
pub fn new(filename: &str) -> Self {
|
| 63 |
+
Self {
|
| 64 |
+
pb: progress_bar(filename),
|
| 65 |
+
filename: filename.to_string(),
|
| 66 |
+
downloaded: Arc::new(AtomicU64::new(0)),
|
| 67 |
+
total: 0,
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
impl Progress for Reporter {
|
| 73 |
+
async fn init(&mut self, size: usize, filename: &str) {
|
| 74 |
+
self.filename = filename.to_string();
|
| 75 |
+
self.downloaded.store(0, Ordering::Relaxed);
|
| 76 |
+
self.total = size as u64;
|
| 77 |
+
self.pb.set_length(size as u64);
|
| 78 |
+
self.pb.set_position(0);
|
| 79 |
+
emit(DownloadProgress {
|
| 80 |
+
filename: self.filename.clone(),
|
| 81 |
+
downloaded: 0,
|
| 82 |
+
total: Some(self.total),
|
| 83 |
+
status: Status::Started,
|
| 84 |
+
});
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
async fn update(&mut self, size: usize) {
|
| 88 |
+
let current = self.downloaded.fetch_add(size as u64, Ordering::Relaxed) + size as u64;
|
| 89 |
+
self.pb.inc(size as u64);
|
| 90 |
+
emit(DownloadProgress {
|
| 91 |
+
filename: self.filename.clone(),
|
| 92 |
+
downloaded: current,
|
| 93 |
+
total: Some(self.total),
|
| 94 |
+
status: Status::Downloading,
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
async fn finish(&mut self) {
|
| 99 |
+
self.pb.finish_and_clear();
|
| 100 |
+
emit(DownloadProgress {
|
| 101 |
+
filename: self.filename.clone(),
|
| 102 |
+
downloaded: self.downloaded.load(Ordering::Relaxed),
|
| 103 |
+
total: Some(self.total),
|
| 104 |
+
status: Status::Completed,
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
}
|
koharu-core/src/http.rs
CHANGED
|
@@ -1,16 +1,11 @@
|
|
| 1 |
-
use std::sync::Arc;
|
| 2 |
-
|
| 3 |
use anyhow::Context;
|
| 4 |
-
use futures::
|
| 5 |
use once_cell::sync::Lazy;
|
| 6 |
-
use reqwest::header::
|
| 7 |
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
|
| 8 |
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
|
| 9 |
|
| 10 |
-
use crate::progress::progress_bar;
|
| 11 |
-
|
| 12 |
const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
| 13 |
-
const RANGE_CHUNK_SIZE_BYTES: usize = 16 * 1024 * 1024;
|
| 14 |
|
| 15 |
static HTTP_CLIENT: Lazy<ClientWithMiddleware> = Lazy::new(|| {
|
| 16 |
ClientBuilder::new(
|
|
@@ -29,91 +24,7 @@ pub fn http_client() -> &'static ClientWithMiddleware {
|
|
| 29 |
&HTTP_CLIENT
|
| 30 |
}
|
| 31 |
|
| 32 |
-
|
| 33 |
-
pub async fn http_download(url: &str) -> anyhow::Result<Vec<u8>> {
|
| 34 |
-
let head = HTTP_CLIENT
|
| 35 |
-
.head(url)
|
| 36 |
-
.send()
|
| 37 |
-
.await?
|
| 38 |
-
.error_for_status()
|
| 39 |
-
.context(format!("cannot download {url}"))?;
|
| 40 |
-
let headers = head.headers();
|
| 41 |
-
let total_bytes = headers
|
| 42 |
-
.get(CONTENT_LENGTH)
|
| 43 |
-
.and_then(|value| value.to_str().ok())
|
| 44 |
-
.and_then(|value| value.parse::<u64>().ok())
|
| 45 |
-
.context("missing Content-Length header")?;
|
| 46 |
-
|
| 47 |
-
anyhow::ensure!(total_bytes > 0, "resource reports zero Content-Length");
|
| 48 |
-
|
| 49 |
-
let supports_ranges = headers
|
| 50 |
-
.get(ACCEPT_RANGES)
|
| 51 |
-
.and_then(|value| value.to_str().ok())
|
| 52 |
-
.map(|value| value.eq_ignore_ascii_case("bytes"))
|
| 53 |
-
.unwrap_or(false);
|
| 54 |
-
|
| 55 |
-
anyhow::ensure!(
|
| 56 |
-
supports_ranges,
|
| 57 |
-
"remote server does not advertise byte ranges"
|
| 58 |
-
);
|
| 59 |
-
|
| 60 |
-
let pb = Arc::new(progress_bar(url.split('/').next_back().unwrap_or(url)));
|
| 61 |
-
|
| 62 |
-
let total_len =
|
| 63 |
-
usize::try_from(total_bytes).context("resource too large to fit into memory")?;
|
| 64 |
-
let chunk_size = total_len.clamp(1, RANGE_CHUNK_SIZE_BYTES);
|
| 65 |
-
let segments = total_len.div_ceil(chunk_size);
|
| 66 |
-
|
| 67 |
-
pb.set_length(total_len as u64);
|
| 68 |
-
|
| 69 |
-
tracing::debug!(
|
| 70 |
-
%url,
|
| 71 |
-
total_bytes,
|
| 72 |
-
segments,
|
| 73 |
-
"downloading resource via HTTP range requests"
|
| 74 |
-
);
|
| 75 |
-
|
| 76 |
-
let chunks = {
|
| 77 |
-
let url = Arc::new(url.to_string());
|
| 78 |
-
let pb = Arc::clone(&pb);
|
| 79 |
-
stream::iter((0..segments).map(move |index| {
|
| 80 |
-
let start = (index * chunk_size) as u64;
|
| 81 |
-
let len = ((index + 1) * chunk_size).min(total_len) - (index * chunk_size);
|
| 82 |
-
let end = start + len as u64 - 1;
|
| 83 |
-
let url = Arc::clone(&url);
|
| 84 |
-
let pb = Arc::clone(&pb);
|
| 85 |
-
async move {
|
| 86 |
-
let chunk = http_chunk(&url, start, end).await?;
|
| 87 |
-
pb.inc(len as u64);
|
| 88 |
-
Ok::<_, anyhow::Error>((start, chunk))
|
| 89 |
-
}
|
| 90 |
-
}))
|
| 91 |
-
.buffer_unordered(segments)
|
| 92 |
-
.try_collect::<Vec<_>>()
|
| 93 |
-
.await?
|
| 94 |
-
};
|
| 95 |
-
|
| 96 |
-
pb.finish_and_clear();
|
| 97 |
-
|
| 98 |
-
let mut parts = chunks;
|
| 99 |
-
parts.sort_by_key(|(start, _)| *start);
|
| 100 |
-
|
| 101 |
-
let mut buffer = Vec::with_capacity(total_len);
|
| 102 |
-
for (_start, mut chunk) in parts {
|
| 103 |
-
buffer.append(&mut chunk);
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
anyhow::ensure!(
|
| 107 |
-
buffer.len() == total_len,
|
| 108 |
-
"range assembly mismatch: expected {} bytes, got {}",
|
| 109 |
-
total_len,
|
| 110 |
-
buffer.len()
|
| 111 |
-
);
|
| 112 |
-
|
| 113 |
-
Ok(buffer)
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
async fn http_chunk(url: &str, start: u64, end: u64) -> anyhow::Result<Vec<u8>> {
|
| 117 |
let expected_len = usize::try_from(end - start + 1)?;
|
| 118 |
let response = HTTP_CLIENT
|
| 119 |
.get(url)
|
|
|
|
|
|
|
|
|
|
| 1 |
use anyhow::Context;
|
| 2 |
+
use futures::StreamExt;
|
| 3 |
use once_cell::sync::Lazy;
|
| 4 |
+
use reqwest::header::RANGE;
|
| 5 |
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
|
| 6 |
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
|
| 7 |
|
|
|
|
|
|
|
| 8 |
const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
|
|
|
| 9 |
|
| 10 |
static HTTP_CLIENT: Lazy<ClientWithMiddleware> = Lazy::new(|| {
|
| 11 |
ClientBuilder::new(
|
|
|
|
| 24 |
&HTTP_CLIENT
|
| 25 |
}
|
| 26 |
|
| 27 |
+
pub async fn http_chunk(url: &str, start: u64, end: u64) -> anyhow::Result<Vec<u8>> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
let expected_len = usize::try_from(end - start + 1)?;
|
| 29 |
let response = HTTP_CLIENT
|
| 30 |
.get(url)
|
koharu-core/src/lib.rs
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
|
|
|
|
|
| 1 |
pub mod http;
|
| 2 |
pub mod progress;
|
|
|
|
| 1 |
+
pub mod download;
|
| 2 |
+
pub mod hf_hub;
|
| 3 |
pub mod http;
|
| 4 |
pub mod progress;
|
koharu-ml/Cargo.toml
CHANGED
|
@@ -17,7 +17,6 @@ anyhow = { workspace = true }
|
|
| 17 |
clap = { workspace = true }
|
| 18 |
image = { workspace = true }
|
| 19 |
imageproc = { workspace = true }
|
| 20 |
-
hf-hub = { workspace = true }
|
| 21 |
candle-core = { workspace = true }
|
| 22 |
candle-transformers = { workspace = true }
|
| 23 |
candle-nn = { workspace = true }
|
|
@@ -30,7 +29,6 @@ tracing-subscriber = { workspace = true }
|
|
| 30 |
tokio = { workspace = true }
|
| 31 |
rustfft = { workspace = true }
|
| 32 |
once_cell = { workspace = true }
|
| 33 |
-
dirs = { workspace = true }
|
| 34 |
futures = { workspace = true }
|
| 35 |
num_cpus = { workspace = true }
|
| 36 |
libloading = { workspace = true }
|
|
@@ -62,6 +60,9 @@ metal = [
|
|
| 62 |
"objc2-metal-performance-shaders-graph",
|
| 63 |
]
|
| 64 |
|
|
|
|
|
|
|
|
|
|
| 65 |
[[bin]]
|
| 66 |
name = "comic-text-detector"
|
| 67 |
path = "bin/comic-text-detector.rs"
|
|
|
|
| 17 |
clap = { workspace = true }
|
| 18 |
image = { workspace = true }
|
| 19 |
imageproc = { workspace = true }
|
|
|
|
| 20 |
candle-core = { workspace = true }
|
| 21 |
candle-transformers = { workspace = true }
|
| 22 |
candle-nn = { workspace = true }
|
|
|
|
| 29 |
tokio = { workspace = true }
|
| 30 |
rustfft = { workspace = true }
|
| 31 |
once_cell = { workspace = true }
|
|
|
|
| 32 |
futures = { workspace = true }
|
| 33 |
num_cpus = { workspace = true }
|
| 34 |
libloading = { workspace = true }
|
|
|
|
| 60 |
"objc2-metal-performance-shaders-graph",
|
| 61 |
]
|
| 62 |
|
| 63 |
+
[dev-dependencies]
|
| 64 |
+
dirs = { workspace = true }
|
| 65 |
+
|
| 66 |
[[bin]]
|
| 67 |
name = "comic-text-detector"
|
| 68 |
path = "bin/comic-text-detector.rs"
|
koharu-ml/src/hf_hub.rs
CHANGED
|
@@ -1,57 +1,7 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
use hf_hub::
|
| 5 |
-
Cache, Repo,
|
| 6 |
-
api::tokio::{Api, ApiBuilder},
|
| 7 |
-
};
|
| 8 |
-
use koharu_core::progress::progress_bar;
|
| 9 |
-
use once_cell::sync::{Lazy, OnceCell};
|
| 10 |
-
|
| 11 |
-
static CACHE_DIR: OnceCell<PathBuf> = OnceCell::new();
|
| 12 |
-
|
| 13 |
-
static HF_API: Lazy<Api> = Lazy::new(|| {
|
| 14 |
-
ApiBuilder::new()
|
| 15 |
-
.with_cache_dir(get_cache_dir().to_path_buf())
|
| 16 |
-
.high()
|
| 17 |
-
.build()
|
| 18 |
-
.expect("build HF API client")
|
| 19 |
-
});
|
| 20 |
-
static HF_CACHE: Lazy<Cache> = Lazy::new(|| Cache::new(get_cache_dir().to_path_buf()));
|
| 21 |
-
|
| 22 |
-
fn get_cache_dir() -> &'static PathBuf {
|
| 23 |
-
CACHE_DIR.get_or_init(|| {
|
| 24 |
-
dirs::cache_dir()
|
| 25 |
-
.unwrap_or_default()
|
| 26 |
-
.join("Koharu")
|
| 27 |
-
.join("models")
|
| 28 |
-
})
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
pub fn set_cache_dir(path: PathBuf) -> anyhow::Result<()> {
|
| 32 |
-
CACHE_DIR
|
| 33 |
-
.set(path)
|
| 34 |
-
.map_err(|_| anyhow::anyhow!("cache dir has already been set"))
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
pub async fn hf_download(repo: &str, filename: &str) -> anyhow::Result<PathBuf> {
|
| 38 |
-
let hf_repo = Repo::model(repo.to_string());
|
| 39 |
-
if let Some(path) = HF_CACHE.repo(hf_repo.clone()).get(filename) {
|
| 40 |
-
return Ok(path);
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
let span = tracing::info_span!("hf_download", repo, filename);
|
| 44 |
-
let _enter = span.enter();
|
| 45 |
-
|
| 46 |
-
let pb = progress_bar(filename);
|
| 47 |
-
let path = HF_API
|
| 48 |
-
.repo(hf_repo)
|
| 49 |
-
.download_with_progress(filename, pb)
|
| 50 |
-
.await
|
| 51 |
-
.context("failed to download from HF Hub")?;
|
| 52 |
-
|
| 53 |
-
Ok(path)
|
| 54 |
-
}
|
| 55 |
|
| 56 |
#[macro_export]
|
| 57 |
macro_rules! define_models {
|
|
@@ -67,10 +17,9 @@ macro_rules! define_models {
|
|
| 67 |
impl Manifest {
|
| 68 |
pub async fn get(&self) -> anyhow::Result<std::path::PathBuf> {
|
| 69 |
use strum::EnumProperty;
|
| 70 |
-
use $crate::hf_hub::hf_download;
|
| 71 |
let repo = self.get_str("repo").expect("repo property");
|
| 72 |
let filename = self.get_str("filename").expect("filename property");
|
| 73 |
-
|
| 74 |
}
|
| 75 |
}
|
| 76 |
|
|
|
|
| 1 |
+
#[allow(unused_imports)]
|
| 2 |
+
pub use koharu_core::download::model;
|
| 3 |
+
#[allow(unused_imports)]
|
| 4 |
+
pub use koharu_core::hf_hub::set_cache_dir;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
#[macro_export]
|
| 7 |
macro_rules! define_models {
|
|
|
|
| 17 |
impl Manifest {
|
| 18 |
pub async fn get(&self) -> anyhow::Result<std::path::PathBuf> {
|
| 19 |
use strum::EnumProperty;
|
|
|
|
| 20 |
let repo = self.get_str("repo").expect("repo property");
|
| 21 |
let filename = self.get_str("filename").expect("filename property");
|
| 22 |
+
koharu_core::download::model(repo, filename).await
|
| 23 |
}
|
| 24 |
}
|
| 25 |
|
koharu-ml/src/lib.rs
CHANGED
|
@@ -9,7 +9,7 @@ pub mod manga_ocr;
|
|
| 9 |
use anyhow::Result;
|
| 10 |
use candle_core::{Device, utils::metal_is_available};
|
| 11 |
|
| 12 |
-
pub use hf_hub::set_cache_dir;
|
| 13 |
pub use llm::{language_from_tag, set_default_locale, set_locale, supported_locales};
|
| 14 |
|
| 15 |
/// Name of the compute device being used.
|
|
|
|
| 9 |
use anyhow::Result;
|
| 10 |
use candle_core::{Device, utils::metal_is_available};
|
| 11 |
|
| 12 |
+
pub use koharu_core::hf_hub::set_cache_dir;
|
| 13 |
pub use llm::{language_from_tag, set_default_locale, set_locale, supported_locales};
|
| 14 |
|
| 15 |
/// Name of the compute device being used.
|
koharu-runtime/src/dylib.rs
CHANGED
|
@@ -7,7 +7,8 @@ use std::{
|
|
| 7 |
|
| 8 |
use anyhow::{Context, Result};
|
| 9 |
use futures::stream::{self, StreamExt, TryStreamExt};
|
| 10 |
-
use koharu_core::
|
|
|
|
| 11 |
use once_cell::sync::OnceCell;
|
| 12 |
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
| 13 |
use tokio::task;
|
|
@@ -281,7 +282,7 @@ async fn fetch_and_extract(pkg: &str, platform_tags: &[&str], out_dir: Arc<PathB
|
|
| 281 |
|
| 282 |
if needs_download {
|
| 283 |
debug!("{pkg}: downloading {wheel_name}...");
|
| 284 |
-
let bytes =
|
| 285 |
.await
|
| 286 |
.context("failed to download wheel")?;
|
| 287 |
let out = Arc::clone(&out_dir);
|
|
|
|
| 7 |
|
| 8 |
use anyhow::{Context, Result};
|
| 9 |
use futures::stream::{self, StreamExt, TryStreamExt};
|
| 10 |
+
use koharu_core::download;
|
| 11 |
+
use koharu_core::http::http_client;
|
| 12 |
use once_cell::sync::OnceCell;
|
| 13 |
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
| 14 |
use tokio::task;
|
|
|
|
| 282 |
|
| 283 |
if needs_download {
|
| 284 |
debug!("{pkg}: downloading {wheel_name}...");
|
| 285 |
+
let bytes = download::bytes(&wheel_url)
|
| 286 |
.await
|
| 287 |
.context("failed to download wheel")?;
|
| 288 |
let out = Arc::clone(&out_dir);
|
koharu/Cargo.toml
CHANGED
|
@@ -22,10 +22,12 @@ include = [
|
|
| 22 |
]
|
| 23 |
|
| 24 |
[dependencies]
|
|
|
|
| 25 |
koharu-ml = { workspace = true }
|
| 26 |
koharu-renderer = { workspace = true }
|
| 27 |
koharu-runtime = { workspace = true }
|
| 28 |
anyhow = { workspace = true }
|
|
|
|
| 29 |
rfd = { workspace = true }
|
| 30 |
open = { workspace = true }
|
| 31 |
serde = { workspace = true }
|
|
|
|
| 22 |
]
|
| 23 |
|
| 24 |
[dependencies]
|
| 25 |
+
koharu-core = { workspace = true }
|
| 26 |
koharu-ml = { workspace = true }
|
| 27 |
koharu-renderer = { workspace = true }
|
| 28 |
koharu-runtime = { workspace = true }
|
| 29 |
anyhow = { workspace = true }
|
| 30 |
+
futures = { workspace = true }
|
| 31 |
rfd = { workspace = true }
|
| 32 |
open = { workspace = true }
|
| 33 |
serde = { workspace = true }
|
koharu/src/app.rs
CHANGED
|
@@ -251,7 +251,10 @@ pub async fn run() -> Result<()> {
|
|
| 251 |
}
|
| 252 |
|
| 253 |
tauri::Builder::default()
|
| 254 |
-
.invoke_handler(tauri::generate_handler![
|
|
|
|
|
|
|
|
|
|
| 255 |
.manage(AtomicU16::new(0))
|
| 256 |
.setup(move |app| {
|
| 257 |
let handle = app.handle().clone();
|
|
|
|
| 251 |
}
|
| 252 |
|
| 253 |
tauri::Builder::default()
|
| 254 |
+
.invoke_handler(tauri::generate_handler![
|
| 255 |
+
command::initialize,
|
| 256 |
+
command::download_progress,
|
| 257 |
+
])
|
| 258 |
.manage(AtomicU16::new(0))
|
| 259 |
.setup(move |app| {
|
| 260 |
let handle = app.handle().clone();
|
koharu/src/command.rs
CHANGED
|
@@ -1,23 +1,39 @@
|
|
| 1 |
use std::sync::atomic::{AtomicU16, Ordering};
|
| 2 |
|
| 3 |
use tauri::State;
|
|
|
|
| 4 |
|
| 5 |
use crate::result::Result;
|
| 6 |
|
| 7 |
#[tauri::command]
|
| 8 |
pub async fn initialize(port: State<'_, AtomicU16>) -> Result<u16> {
|
| 9 |
-
let mut attempts = 0;
|
| 10 |
loop {
|
| 11 |
let p = port.load(Ordering::SeqCst);
|
| 12 |
if p != 0 {
|
| 13 |
return Ok(p);
|
| 14 |
}
|
| 15 |
|
| 16 |
-
if attempts >= 100 {
|
| 17 |
-
return Err(anyhow::anyhow!("HTTP server failed to start").into());
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
attempts += 1;
|
| 21 |
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
| 22 |
}
|
| 23 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
use std::sync::atomic::{AtomicU16, Ordering};
|
| 2 |
|
| 3 |
use tauri::State;
|
| 4 |
+
use tauri::ipc::Channel;
|
| 5 |
|
| 6 |
use crate::result::Result;
|
| 7 |
|
| 8 |
#[tauri::command]
|
| 9 |
pub async fn initialize(port: State<'_, AtomicU16>) -> Result<u16> {
|
|
|
|
| 10 |
loop {
|
| 11 |
let p = port.load(Ordering::SeqCst);
|
| 12 |
if p != 0 {
|
| 13 |
return Ok(p);
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
| 17 |
}
|
| 18 |
}
|
| 19 |
+
|
| 20 |
+
#[tauri::command]
|
| 21 |
+
pub async fn download_progress(
|
| 22 |
+
channel: Channel<koharu_core::download::DownloadProgress>,
|
| 23 |
+
) -> Result<()> {
|
| 24 |
+
let mut rx = koharu_core::download::subscribe();
|
| 25 |
+
tokio::spawn(async move {
|
| 26 |
+
loop {
|
| 27 |
+
match rx.recv().await {
|
| 28 |
+
Ok(progress) => {
|
| 29 |
+
if channel.send(progress).is_err() {
|
| 30 |
+
break;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
| 34 |
+
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
});
|
| 38 |
+
Ok(())
|
| 39 |
+
}
|
koharu/src/endpoints.rs
CHANGED
|
@@ -1,11 +1,16 @@
|
|
| 1 |
use std::{io::Cursor, path::PathBuf, str::FromStr};
|
| 2 |
|
|
|
|
|
|
|
| 3 |
use axum::{
|
| 4 |
Json,
|
| 5 |
body::Body,
|
| 6 |
extract::{Multipart, State},
|
| 7 |
http::{HeaderValue, StatusCode, header},
|
| 8 |
-
response::
|
|
|
|
|
|
|
|
|
|
| 9 |
};
|
| 10 |
use image::{GenericImageView, ImageFormat, RgbaImage};
|
| 11 |
use koharu_ml::llm::ModelId;
|
|
@@ -829,3 +834,18 @@ fn blank_rgba(width: u32, height: u32, color: image::Rgba<u8>) -> SerializableDy
|
|
| 829 |
let blank = RgbaImage::from_pixel(width, height, color);
|
| 830 |
image::DynamicImage::ImageRgba8(blank).into()
|
| 831 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
use std::{io::Cursor, path::PathBuf, str::FromStr};
|
| 2 |
|
| 3 |
+
use std::convert::Infallible;
|
| 4 |
+
|
| 5 |
use axum::{
|
| 6 |
Json,
|
| 7 |
body::Body,
|
| 8 |
extract::{Multipart, State},
|
| 9 |
http::{HeaderValue, StatusCode, header},
|
| 10 |
+
response::{
|
| 11 |
+
Response,
|
| 12 |
+
sse::{Event, KeepAlive, Sse},
|
| 13 |
+
},
|
| 14 |
};
|
| 15 |
use image::{GenericImageView, ImageFormat, RgbaImage};
|
| 16 |
use koharu_ml::llm::ModelId;
|
|
|
|
| 834 |
let blank = RgbaImage::from_pixel(width, height, color);
|
| 835 |
image::DynamicImage::ImageRgba8(blank).into()
|
| 836 |
}
|
| 837 |
+
|
| 838 |
+
pub async fn download_progress()
|
| 839 |
+
-> Sse<impl futures::Stream<Item = std::result::Result<Event, Infallible>>> {
|
| 840 |
+
let rx = koharu_core::download::subscribe();
|
| 841 |
+
let stream = futures::stream::unfold(rx, |mut rx| async {
|
| 842 |
+
loop {
|
| 843 |
+
match rx.recv().await {
|
| 844 |
+
Ok(p) => return Some((Ok(Event::default().json_data(p).unwrap()), rx)),
|
| 845 |
+
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
| 846 |
+
Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
|
| 847 |
+
}
|
| 848 |
+
}
|
| 849 |
+
});
|
| 850 |
+
Sse::new(stream).keep_alive(KeepAlive::default())
|
| 851 |
+
}
|
koharu/src/server.rs
CHANGED
|
@@ -75,6 +75,7 @@ fn build_router(state: AppResources) -> Router {
|
|
| 75 |
.route("/api/llm_offload", post(llm_offload))
|
| 76 |
.route("/api/llm_ready", get(llm_ready).post(llm_ready))
|
| 77 |
.route("/api/llm_generate", post(llm_generate))
|
|
|
|
| 78 |
.with_state(state)
|
| 79 |
.layer(DefaultBodyLimit::max(1024 * 1024 * 1024))
|
| 80 |
.layer(
|
|
|
|
| 75 |
.route("/api/llm_offload", post(llm_offload))
|
| 76 |
.route("/api/llm_ready", get(llm_ready).post(llm_ready))
|
| 77 |
.route("/api/llm_generate", post(llm_generate))
|
| 78 |
+
.route("/api/download_progress", get(download_progress))
|
| 79 |
.with_state(state)
|
| 80 |
.layer(DefaultBodyLimit::max(1024 * 1024 * 1024))
|
| 81 |
.layer(
|
koharu/tauri.conf.json
CHANGED
|
@@ -28,8 +28,8 @@
|
|
| 28 |
"url": "/splashscreen",
|
| 29 |
"decorations": false,
|
| 30 |
"resizable": false,
|
| 31 |
-
"height":
|
| 32 |
-
"width":
|
| 33 |
"center": true
|
| 34 |
}
|
| 35 |
]
|
|
|
|
| 28 |
"url": "/splashscreen",
|
| 29 |
"decorations": false,
|
| 30 |
"resizable": false,
|
| 31 |
+
"height": 200,
|
| 32 |
+
"width": 300,
|
| 33 |
"center": true
|
| 34 |
}
|
| 35 |
]
|
ui/app/splashscreen/page.tsx
CHANGED
|
@@ -1,9 +1,83 @@
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { useTranslation } from 'react-i18next'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export default function SplashScreen() {
|
| 6 |
const { t } = useTranslation()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
return (
|
| 9 |
<main className='bg-background flex min-h-screen flex-col items-center justify-center select-none'>
|
|
@@ -11,6 +85,26 @@ export default function SplashScreen() {
|
|
| 11 |
<span className='text-primary mt-2 text-lg'>
|
| 12 |
{t('common.initializing')}
|
| 13 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
</main>
|
| 15 |
)
|
| 16 |
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useEffect, useRef, useState } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
+
import { isTauri, type DownloadProgress } from '@/lib/backend'
|
| 6 |
+
|
| 7 |
+
type AggregateProgress = {
|
| 8 |
+
filename: string
|
| 9 |
+
percent?: number
|
| 10 |
+
}
|
| 11 |
|
| 12 |
export default function SplashScreen() {
|
| 13 |
const { t } = useTranslation()
|
| 14 |
+
const [progress, setProgress] = useState<AggregateProgress | null>(null)
|
| 15 |
+
const channelRef = useRef<unknown>(null)
|
| 16 |
+
const filesRef = useRef<Map<string, { downloaded: number; total: number }>>(
|
| 17 |
+
new Map(),
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
if (!isTauri()) return
|
| 22 |
+
;(async () => {
|
| 23 |
+
const { Channel, invoke } = await import('@tauri-apps/api/core')
|
| 24 |
+
const channel = new Channel<DownloadProgress>()
|
| 25 |
+
channelRef.current = channel
|
| 26 |
+
channel.onmessage = (msg) => {
|
| 27 |
+
const files = filesRef.current
|
| 28 |
+
|
| 29 |
+
if (msg.status === 'Started') {
|
| 30 |
+
files.set(msg.filename, { downloaded: 0, total: msg.total ?? 0 })
|
| 31 |
+
} else if (msg.status === 'Downloading') {
|
| 32 |
+
const entry = files.get(msg.filename)
|
| 33 |
+
if (entry) {
|
| 34 |
+
entry.downloaded = msg.downloaded
|
| 35 |
+
if (msg.total) entry.total = msg.total
|
| 36 |
+
} else {
|
| 37 |
+
files.set(msg.filename, {
|
| 38 |
+
downloaded: msg.downloaded,
|
| 39 |
+
total: msg.total ?? 0,
|
| 40 |
+
})
|
| 41 |
+
}
|
| 42 |
+
} else {
|
| 43 |
+
// Completed or Failed — lock this file at 100%
|
| 44 |
+
const entry = files.get(msg.filename)
|
| 45 |
+
if (entry) {
|
| 46 |
+
entry.downloaded = entry.total
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Compute aggregate
|
| 51 |
+
let totalBytes = 0
|
| 52 |
+
let downloadedBytes = 0
|
| 53 |
+
for (const entry of files.values()) {
|
| 54 |
+
totalBytes += entry.total
|
| 55 |
+
downloadedBytes += entry.downloaded
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Find current active file (last non-completed)
|
| 59 |
+
const activeFilename =
|
| 60 |
+
msg.status === 'Started' || msg.status === 'Downloading'
|
| 61 |
+
? msg.filename
|
| 62 |
+
: null
|
| 63 |
+
|
| 64 |
+
const percent =
|
| 65 |
+
totalBytes > 0
|
| 66 |
+
? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
|
| 67 |
+
: undefined
|
| 68 |
+
|
| 69 |
+
if (activeFilename) {
|
| 70 |
+
setProgress({ filename: activeFilename, percent })
|
| 71 |
+
} else {
|
| 72 |
+
// All done — keep showing 100% briefly
|
| 73 |
+
setProgress((prev) =>
|
| 74 |
+
prev ? { ...prev, percent: percent ?? 100 } : null,
|
| 75 |
+
)
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
await invoke('download_progress', { channel })
|
| 79 |
+
})()
|
| 80 |
+
}, [])
|
| 81 |
|
| 82 |
return (
|
| 83 |
<main className='bg-background flex min-h-screen flex-col items-center justify-center select-none'>
|
|
|
|
| 85 |
<span className='text-primary mt-2 text-lg'>
|
| 86 |
{t('common.initializing')}
|
| 87 |
</span>
|
| 88 |
+
<div className='mt-4 flex h-12 w-48 flex-col items-center gap-1.5'>
|
| 89 |
+
<span className='text-muted-foreground h-4 max-w-full truncate text-xs'>
|
| 90 |
+
{progress ? progress.filename : '\u00a0'}
|
| 91 |
+
</span>
|
| 92 |
+
<div className='bg-muted relative h-1.5 w-full overflow-hidden rounded-full'>
|
| 93 |
+
{progress && typeof progress.percent === 'number' ? (
|
| 94 |
+
<div
|
| 95 |
+
className='bg-primary h-full rounded-full transition-[width] duration-300'
|
| 96 |
+
style={{ width: `${progress.percent}%` }}
|
| 97 |
+
/>
|
| 98 |
+
) : (
|
| 99 |
+
<div className='activity-progress-indeterminate from-primary/40 via-primary to-primary/40 absolute inset-0 w-1/2 rounded-full bg-linear-to-r' />
|
| 100 |
+
)}
|
| 101 |
+
</div>
|
| 102 |
+
<span className='text-muted-foreground h-4 text-[11px] tabular-nums'>
|
| 103 |
+
{progress && typeof progress.percent === 'number'
|
| 104 |
+
? `${progress.percent}%`
|
| 105 |
+
: '\u00a0'}
|
| 106 |
+
</span>
|
| 107 |
+
</div>
|
| 108 |
</main>
|
| 109 |
)
|
| 110 |
}
|
ui/components/ActivityBubble.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { type ReactNode } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
import { useAppStore } from '@/lib/store'
|
|
|
|
| 6 |
import { Button } from '@/components/ui/button'
|
| 7 |
import { type OperationState } from '@/lib/operations'
|
| 8 |
|
|
@@ -21,6 +22,55 @@ function BubbleCard({ children }: { children: ReactNode }) {
|
|
| 21 |
)
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
function OperationCard({
|
| 25 |
operation,
|
| 26 |
onCancel,
|
|
@@ -116,23 +166,7 @@ function OperationCard({
|
|
| 116 |
</span>
|
| 117 |
) : null}
|
| 118 |
</div>
|
| 119 |
-
<
|
| 120 |
-
<div className='bg-muted relative h-1.5 flex-1 overflow-hidden rounded-full'>
|
| 121 |
-
{typeof progress === 'number' ? (
|
| 122 |
-
<div
|
| 123 |
-
className='bg-primary h-full rounded-full transition-[width] duration-300'
|
| 124 |
-
style={{ width: `${progress}%` }}
|
| 125 |
-
/>
|
| 126 |
-
) : (
|
| 127 |
-
<div className='activity-progress-indeterminate from-primary/40 via-primary to-primary/40 absolute inset-0 w-1/2 rounded-full bg-linear-to-r' />
|
| 128 |
-
)}
|
| 129 |
-
</div>
|
| 130 |
-
{typeof progress === 'number' && (
|
| 131 |
-
<span className='text-muted-foreground w-12 text-right text-[11px] font-semibold tabular-nums'>
|
| 132 |
-
{progress}%
|
| 133 |
-
</span>
|
| 134 |
-
)}
|
| 135 |
-
</div>
|
| 136 |
{operation.cancellable && (
|
| 137 |
<div className='mt-3 flex justify-end'>
|
| 138 |
<Button
|
|
@@ -158,12 +192,32 @@ export function ActivityBubble() {
|
|
| 158 |
const { t } = useTranslation()
|
| 159 |
const operation = useAppStore((state) => state.operation)
|
| 160 |
const cancelOperation = useAppStore((state) => state.cancelOperation)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
-
if (!operation) return null
|
| 163 |
|
| 164 |
return (
|
| 165 |
<div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
</div>
|
| 168 |
)
|
| 169 |
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { type ReactNode, useEffect } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
import { useAppStore } from '@/lib/store'
|
| 6 |
+
import { useDownloadStore } from '@/lib/downloads'
|
| 7 |
import { Button } from '@/components/ui/button'
|
| 8 |
import { type OperationState } from '@/lib/operations'
|
| 9 |
|
|
|
|
| 22 |
)
|
| 23 |
}
|
| 24 |
|
| 25 |
+
function ProgressBar({ percent }: { percent?: number }) {
|
| 26 |
+
return (
|
| 27 |
+
<div className='mt-3 flex items-center gap-2'>
|
| 28 |
+
<div className='bg-muted relative h-1.5 flex-1 overflow-hidden rounded-full'>
|
| 29 |
+
{typeof percent === 'number' ? (
|
| 30 |
+
<div
|
| 31 |
+
className='bg-primary h-full rounded-full transition-[width] duration-300'
|
| 32 |
+
style={{ width: `${percent}%` }}
|
| 33 |
+
/>
|
| 34 |
+
) : (
|
| 35 |
+
<div className='activity-progress-indeterminate from-primary/40 via-primary to-primary/40 absolute inset-0 w-1/2 rounded-full bg-linear-to-r' />
|
| 36 |
+
)}
|
| 37 |
+
</div>
|
| 38 |
+
{typeof percent === 'number' && (
|
| 39 |
+
<span className='text-muted-foreground w-12 text-right text-[11px] font-semibold tabular-nums'>
|
| 40 |
+
{percent}%
|
| 41 |
+
</span>
|
| 42 |
+
)}
|
| 43 |
+
</div>
|
| 44 |
+
)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function DownloadCard({
|
| 48 |
+
filename,
|
| 49 |
+
percent,
|
| 50 |
+
t,
|
| 51 |
+
}: {
|
| 52 |
+
filename: string
|
| 53 |
+
percent?: number
|
| 54 |
+
t: TranslateFunc
|
| 55 |
+
}) {
|
| 56 |
+
return (
|
| 57 |
+
<BubbleCard>
|
| 58 |
+
<div className='flex items-start gap-3'>
|
| 59 |
+
<div className='bg-primary mt-1 h-2.5 w-2.5 animate-pulse rounded-full shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
|
| 60 |
+
<div className='flex-1'>
|
| 61 |
+
<div className='text-foreground text-sm font-semibold'>
|
| 62 |
+
{t('download.title')}
|
| 63 |
+
</div>
|
| 64 |
+
<div className='text-muted-foreground truncate text-xs'>
|
| 65 |
+
{filename}
|
| 66 |
+
</div>
|
| 67 |
+
<ProgressBar percent={percent} />
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</BubbleCard>
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
function OperationCard({
|
| 75 |
operation,
|
| 76 |
onCancel,
|
|
|
|
| 166 |
</span>
|
| 167 |
) : null}
|
| 168 |
</div>
|
| 169 |
+
<ProgressBar percent={progress} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
{operation.cancellable && (
|
| 171 |
<div className='mt-3 flex justify-end'>
|
| 172 |
<Button
|
|
|
|
| 192 |
const { t } = useTranslation()
|
| 193 |
const operation = useAppStore((state) => state.operation)
|
| 194 |
const cancelOperation = useAppStore((state) => state.cancelOperation)
|
| 195 |
+
const downloads = useDownloadStore((s) => s.downloads)
|
| 196 |
+
const ensureSubscribed = useDownloadStore((s) => s.ensureSubscribed)
|
| 197 |
+
|
| 198 |
+
useEffect(() => {
|
| 199 |
+
ensureSubscribed()
|
| 200 |
+
}, [ensureSubscribed])
|
| 201 |
+
|
| 202 |
+
const activeDownloads = Array.from(downloads.values()).filter(
|
| 203 |
+
(d) => d.status === 'Started' || d.status === 'Downloading',
|
| 204 |
+
)
|
| 205 |
|
| 206 |
+
if (!operation && activeDownloads.length === 0) return null
|
| 207 |
|
| 208 |
return (
|
| 209 |
<div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
|
| 210 |
+
{operation && (
|
| 211 |
+
<OperationCard operation={operation} onCancel={cancelOperation} t={t} />
|
| 212 |
+
)}
|
| 213 |
+
{activeDownloads.map((d) => (
|
| 214 |
+
<DownloadCard
|
| 215 |
+
key={d.filename}
|
| 216 |
+
filename={d.filename}
|
| 217 |
+
percent={d.percent}
|
| 218 |
+
t={t}
|
| 219 |
+
/>
|
| 220 |
+
))}
|
| 221 |
</div>
|
| 222 |
)
|
| 223 |
}
|
ui/lib/backend.ts
CHANGED
|
@@ -224,6 +224,45 @@ export async function fetchThumbnail(index: number): Promise<Blob> {
|
|
| 224 |
return res.blob()
|
| 225 |
}
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
export const isTauri = isTauriEnv
|
| 228 |
|
| 229 |
export const isMacOS = (): boolean => {
|
|
|
|
| 224 |
return res.blob()
|
| 225 |
}
|
| 226 |
|
| 227 |
+
export type DownloadProgress = {
|
| 228 |
+
filename: string
|
| 229 |
+
downloaded: number
|
| 230 |
+
total?: number
|
| 231 |
+
status: 'Started' | 'Downloading' | 'Completed' | { Failed: string }
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
export function subscribeDownloadProgress(
|
| 235 |
+
callback: (progress: DownloadProgress) => void,
|
| 236 |
+
): () => void {
|
| 237 |
+
let stopped = false
|
| 238 |
+
|
| 239 |
+
;(async () => {
|
| 240 |
+
await ensureInitialized()
|
| 241 |
+
const es = new EventSource(`${apiBase}/download_progress`)
|
| 242 |
+
es.onmessage = (event) => {
|
| 243 |
+
if (stopped) {
|
| 244 |
+
es.close()
|
| 245 |
+
return
|
| 246 |
+
}
|
| 247 |
+
try {
|
| 248 |
+
callback(JSON.parse(event.data) as DownloadProgress)
|
| 249 |
+
} catch (_) {}
|
| 250 |
+
}
|
| 251 |
+
const check = () => {
|
| 252 |
+
if (stopped) es.close()
|
| 253 |
+
}
|
| 254 |
+
const id = setInterval(check, 1000)
|
| 255 |
+
es.onerror = () => {
|
| 256 |
+
clearInterval(id)
|
| 257 |
+
es.close()
|
| 258 |
+
}
|
| 259 |
+
})()
|
| 260 |
+
|
| 261 |
+
return () => {
|
| 262 |
+
stopped = true
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
export const isTauri = isTauriEnv
|
| 267 |
|
| 268 |
export const isMacOS = (): boolean => {
|
ui/lib/downloads.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { create } from 'zustand'
|
| 4 |
+
import { subscribeDownloadProgress, type DownloadProgress } from '@/lib/backend'
|
| 5 |
+
|
| 6 |
+
type DownloadEntry = DownloadProgress & {
|
| 7 |
+
percent?: number
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
type DownloadStore = {
|
| 11 |
+
downloads: Map<string, DownloadEntry>
|
| 12 |
+
ensureSubscribed: () => void
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
let subscribed = false
|
| 16 |
+
|
| 17 |
+
export const useDownloadStore = create<DownloadStore>((set, get) => ({
|
| 18 |
+
downloads: new Map(),
|
| 19 |
+
|
| 20 |
+
ensureSubscribed: () => {
|
| 21 |
+
if (subscribed) return
|
| 22 |
+
subscribed = true
|
| 23 |
+
|
| 24 |
+
subscribeDownloadProgress((progress) => {
|
| 25 |
+
const next = new Map(get().downloads)
|
| 26 |
+
const percent =
|
| 27 |
+
progress.total && progress.total > 0
|
| 28 |
+
? Math.round((progress.downloaded / progress.total) * 100)
|
| 29 |
+
: undefined
|
| 30 |
+
|
| 31 |
+
const status = progress.status
|
| 32 |
+
if (
|
| 33 |
+
status === 'Completed' ||
|
| 34 |
+
(typeof status === 'object' && 'Failed' in status)
|
| 35 |
+
) {
|
| 36 |
+
next.set(progress.filename, { ...progress, percent })
|
| 37 |
+
set({ downloads: next })
|
| 38 |
+
setTimeout(() => {
|
| 39 |
+
const current = get().downloads
|
| 40 |
+
if (current.has(progress.filename)) {
|
| 41 |
+
const updated = new Map(current)
|
| 42 |
+
updated.delete(progress.filename)
|
| 43 |
+
set({ downloads: updated })
|
| 44 |
+
}
|
| 45 |
+
}, 3000)
|
| 46 |
+
} else {
|
| 47 |
+
next.set(progress.filename, { ...progress, percent })
|
| 48 |
+
set({ downloads: next })
|
| 49 |
+
}
|
| 50 |
+
})
|
| 51 |
+
},
|
| 52 |
+
}))
|
ui/public/locales/en-US/translation.json
CHANGED
|
@@ -147,6 +147,9 @@
|
|
| 147 |
"repairBrush": "Repair brush",
|
| 148 |
"eraser": "Eraser"
|
| 149 |
},
|
|
|
|
|
|
|
|
|
|
| 150 |
"settings": {
|
| 151 |
"title": "Settings",
|
| 152 |
"appearance": "Appearance",
|
|
|
|
| 147 |
"repairBrush": "Repair brush",
|
| 148 |
"eraser": "Eraser"
|
| 149 |
},
|
| 150 |
+
"download": {
|
| 151 |
+
"title": "Downloading"
|
| 152 |
+
},
|
| 153 |
"settings": {
|
| 154 |
"title": "Settings",
|
| 155 |
"appearance": "Appearance",
|
ui/public/locales/ja-JP/translation.json
CHANGED
|
@@ -147,6 +147,9 @@
|
|
| 147 |
"repairBrush": "リペアブラシ",
|
| 148 |
"eraser": "消しゴム"
|
| 149 |
},
|
|
|
|
|
|
|
|
|
|
| 150 |
"settings": {
|
| 151 |
"title": "設定",
|
| 152 |
"appearance": "外観",
|
|
|
|
| 147 |
"repairBrush": "リペアブラシ",
|
| 148 |
"eraser": "消しゴム"
|
| 149 |
},
|
| 150 |
+
"download": {
|
| 151 |
+
"title": "ダウンロード中"
|
| 152 |
+
},
|
| 153 |
"settings": {
|
| 154 |
"title": "設定",
|
| 155 |
"appearance": "外観",
|
ui/public/locales/zh-CN/translation.json
CHANGED
|
@@ -147,6 +147,9 @@
|
|
| 147 |
"repairBrush": "修复笔",
|
| 148 |
"eraser": "橡皮擦"
|
| 149 |
},
|
|
|
|
|
|
|
|
|
|
| 150 |
"settings": {
|
| 151 |
"title": "设置",
|
| 152 |
"appearance": "外观",
|
|
|
|
| 147 |
"repairBrush": "修复笔",
|
| 148 |
"eraser": "橡皮擦"
|
| 149 |
},
|
| 150 |
+
"download": {
|
| 151 |
+
"title": "下载中"
|
| 152 |
+
},
|
| 153 |
"settings": {
|
| 154 |
"title": "设置",
|
| 155 |
"appearance": "外观",
|
ui/public/locales/zh-TW/translation.json
CHANGED
|
@@ -147,6 +147,9 @@
|
|
| 147 |
"repairBrush": "修復筆",
|
| 148 |
"eraser": "橡皮擦"
|
| 149 |
},
|
|
|
|
|
|
|
|
|
|
| 150 |
"settings": {
|
| 151 |
"title": "設定",
|
| 152 |
"appearance": "外觀",
|
|
|
|
| 147 |
"repairBrush": "修復筆",
|
| 148 |
"eraser": "橡皮擦"
|
| 149 |
},
|
| 150 |
+
"download": {
|
| 151 |
+
"title": "下載中"
|
| 152 |
+
},
|
| 153 |
"settings": {
|
| 154 |
"title": "設定",
|
| 155 |
"appearance": "外觀",
|