release: v0.2.37 — zero-argument startup, first-run wizard, real download progress
Startup no longer needs arguments: the config is created on first launch, a busy port is swapped for a free one, the console is open by default, and the browser lands on a setup wizard that downloads a ready-made .cmf model and wires it into routing (managed server + routing.default) when you press Finish. Download progress was reported from curl's own meter, which prints a full 0→100% bar for the -L redirect hop before the transfer starts — so every job showed 100% and then hung. Progress now comes from bytes on disk against the size from the HuggingFace tree (or a HEAD request). The UI and the gateway also disagreed about the output file name, so a finished download never marked its card as downloaded; the gateway now returns the exact stem and the UI sends it back verbatim for download, progress and delete. Featured models are read live from the infosave HF account in both the wizard and Import, and all 432 UI strings now exist in every one of the 7 languages.
This commit is contained in:
+483
-43
@@ -30,6 +30,47 @@ fn cleanup_output(path: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes an output occupies: a file's length, or the recursive sum for a sharded
|
||||
/// `.cmf` directory. 0 when nothing has been written yet.
|
||||
pub fn path_size(p: &std::path::Path) -> u64 {
|
||||
match std::fs::metadata(p) {
|
||||
Ok(m) if m.is_file() => m.len(),
|
||||
Ok(m) if m.is_dir() => std::fs::read_dir(p)
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.map(|e| path_size(&e.path()))
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts the wrapped task when it goes out of scope — used for the progress
|
||||
/// poller so every early return in a download path stops it.
|
||||
struct AbortOnDrop(tokio::task::JoinHandle<()>);
|
||||
impl Drop for AbortOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive progress from bytes on disk. curl's own meter is unreliable here (its
|
||||
/// bar is redrawn with `\r` into a pipe, and a sharded download runs one curl
|
||||
/// per part), but the output size never lies.
|
||||
fn spawn_size_poller(store: Arc<JobStore>, id: String, path: String) -> AbortOnDrop {
|
||||
AbortOnDrop(tokio::spawn(async move {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
|
||||
let done = path_size(&p);
|
||||
if done > 0 {
|
||||
store.set_bytes(&id, done);
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -37,16 +78,12 @@ fn now() -> u64 {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Job id from a hash of (repo, nanos) — unique without a uuid dependency.
|
||||
fn gen_id(repo: &str) -> String {
|
||||
/// Job id from repo (deterministic) — same repo+variant reuses same id, so
|
||||
/// the job list doesn't fill with duplicates and "already downloaded" can be detected.
|
||||
pub fn gen_id(repo: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
repo.hash(&mut h);
|
||||
nanos.hash(&mut h);
|
||||
format!("{:016x}", h.finish())
|
||||
}
|
||||
|
||||
@@ -58,6 +95,10 @@ pub struct ImportParams {
|
||||
pub quant: String,
|
||||
#[serde(default)]
|
||||
pub name: String, // output basename; empty → derived from repo
|
||||
/// For sharded CMF repos (e.g. DeepSeek with `parts-q2tp-v2`/`parts-q4tp`), which
|
||||
/// variant directory to download. Empty = auto (first .cmf or first variant).
|
||||
#[serde(default)]
|
||||
pub variant: Option<String>,
|
||||
// ── advanced ──
|
||||
#[serde(default)]
|
||||
pub linear_core: Option<String>, // gated_delta_net | vmf_phase
|
||||
@@ -97,6 +138,11 @@ pub struct Job {
|
||||
pub repo: String,
|
||||
pub quant: String,
|
||||
pub output: String,
|
||||
/// Output basename without `.cmf` — the id the UI uses to match a job to the
|
||||
/// featured card that started it, and to the file on disk.
|
||||
pub name: String,
|
||||
/// Sharded-variant directory / `.cmf` filename this job downloads, if any.
|
||||
pub variant: Option<String>,
|
||||
pub state: String, // running | done | error | cancelled
|
||||
pub log: Vec<String>,
|
||||
pub started: u64,
|
||||
@@ -104,6 +150,10 @@ pub struct Job {
|
||||
pub size_bytes: Option<u64>,
|
||||
pub progress: Option<f32>, // 0..1, parsed from converter @PROGRESS
|
||||
pub phase: Option<String>, // current phase label
|
||||
/// Expected download size (from the HF tree) and bytes already on disk —
|
||||
/// what makes the percentage in the UI real rather than a guess.
|
||||
pub total_bytes: Option<u64>,
|
||||
pub done_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -154,12 +204,53 @@ impl JobStore {
|
||||
}
|
||||
fn set_progress(&self, id: &str, frac: f32, phase: String) {
|
||||
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
|
||||
j.progress = Some(frac);
|
||||
let f = frac.clamp(0.0, 1.0);
|
||||
let cur = j.progress.unwrap_or(0.0);
|
||||
// monotonic only — never go backwards (curl reprints, re-download)
|
||||
if f > cur + 0.001 {
|
||||
j.progress = Some(f);
|
||||
} else if j.progress.is_none() {
|
||||
j.progress = Some(f);
|
||||
}
|
||||
if !phase.is_empty() {
|
||||
j.phase = Some(phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Expected total download size, once the HF tree is known.
|
||||
fn set_total(&self, id: &str, total: u64) {
|
||||
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
|
||||
if total > 0 {
|
||||
j.total_bytes = Some(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Report bytes on disk; derives the percentage whenever the total is known.
|
||||
fn set_bytes(&self, id: &str, done: u64) {
|
||||
let total = {
|
||||
let mut g = self.jobs.lock().unwrap();
|
||||
let Some(j) = g.get_mut(id) else { return };
|
||||
if j.state != "running" {
|
||||
return;
|
||||
}
|
||||
if done > j.done_bytes.unwrap_or(0) {
|
||||
j.done_bytes = Some(done);
|
||||
}
|
||||
j.total_bytes
|
||||
};
|
||||
if let Some(t) = total.filter(|t| *t > 0) {
|
||||
self.set_progress(id, done as f32 / t as f32, "downloading".into());
|
||||
}
|
||||
}
|
||||
fn set_phase(&self, id: &str, phase: String) {
|
||||
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
|
||||
j.phase = Some(phase);
|
||||
// keep progress as is; if it was 0.0, make it indeterminate (None) so bar animates
|
||||
if j.progress == Some(0.0) {
|
||||
j.progress = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn update_quant(&self, id: &str, quant: String) {
|
||||
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
|
||||
j.quant = quant.clone();
|
||||
@@ -180,11 +271,14 @@ impl JobStore {
|
||||
return; // a concurrent cancel already settled this job
|
||||
}
|
||||
j.finished = Some(now());
|
||||
j.size_bytes = std::fs::metadata(&j.output).ok().map(|m| m.len());
|
||||
let done = ok && j.size_bytes.unwrap_or(0) > 0;
|
||||
let on_disk = path_size(std::path::Path::new(&j.output));
|
||||
j.size_bytes = if on_disk > 0 { Some(on_disk) } else { None };
|
||||
let done = ok && on_disk > 0;
|
||||
j.state = if done { "done" } else { "error" }.into();
|
||||
if done {
|
||||
j.progress = Some(1.0);
|
||||
j.done_bytes = Some(on_disk);
|
||||
j.total_bytes = Some(j.total_bytes.unwrap_or(on_disk).max(on_disk));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +299,17 @@ impl JobStore {
|
||||
Ok(true)
|
||||
}
|
||||
pub fn list(&self) -> Vec<Job> {
|
||||
let mut v: Vec<Job> = self.jobs.lock().unwrap().values().cloned().collect();
|
||||
let mut map: HashMap<String, Job> = HashMap::new();
|
||||
for j in self.jobs.lock().unwrap().values().cloned() {
|
||||
// dedupe by repo+variant — keep the latest (so re-download doesn't create
|
||||
// duplicate rows) while two variants of one repo stay separate rows
|
||||
let key = format!("{}::{}", j.repo, j.variant.clone().unwrap_or_default());
|
||||
match map.get(&key) {
|
||||
Some(prev) if prev.started >= j.started => {}
|
||||
_ => { map.insert(key, j); }
|
||||
}
|
||||
}
|
||||
let mut v: Vec<Job> = map.into_values().collect();
|
||||
v.sort_by_key(|x| std::cmp::Reverse(x.started));
|
||||
v.truncate(20);
|
||||
v
|
||||
@@ -242,6 +346,149 @@ pub async fn hf_search(
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn hf_search_author(
|
||||
author: &str,
|
||||
limit: usize,
|
||||
token: Option<&str>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let url = format!(
|
||||
"https://huggingface.co/api/models?author={}&limit={}&sort=trendingScore&direction=-1",
|
||||
url_escape(author),
|
||||
limit
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut req = client.get(&url).header("User-Agent", "cortiq-gateway");
|
||||
if let Some(t) = token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HuggingFace API {}", resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn hf_model_size(id: &str, token: Option<&str>) -> Option<u64> {
|
||||
let url = format!("https://huggingface.co/api/models/{}", url_escape(id));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()
|
||||
.ok()?;
|
||||
let mut req = client.get(&url).header("User-Agent", "cortiq-gateway");
|
||||
if let Some(t) = token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
let resp = req.send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let v: serde_json::Value = resp.json().await.ok()?;
|
||||
v.get("usedStorage").and_then(|x| x.as_u64())
|
||||
}
|
||||
|
||||
/// Per-variant sizes for a repo (for CMF repos with sharded directories like
|
||||
/// `parts-q2tp-v2`, `parts-q4tp`). Returns `[(variant_name, bytes)]`. For a
|
||||
/// single-file repo returns one entry. Empty = tree unreachable.
|
||||
pub async fn hf_repo_variants(id: &str, token: Option<&str>) -> Vec<(String, u64)> {
|
||||
let url = format!(
|
||||
"https://huggingface.co/api/models/{}/tree/main?recursive=true",
|
||||
url_escape(id)
|
||||
);
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut req = client.get(&url).header("User-Agent", "cortiq-gateway");
|
||||
if let Some(t) = token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
let resp = match req.send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let arr: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let list = match arr.as_array() {
|
||||
Some(a) => a,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
let mut dir_sizes: HashMap<String, u64> = HashMap::new();
|
||||
let mut single_cmf: Vec<(String, u64)> = Vec::new();
|
||||
for item in list {
|
||||
if item.get("type").and_then(|t| t.as_str()) != Some("file") {
|
||||
continue;
|
||||
}
|
||||
let path = match item.get("path").and_then(|p| p.as_str()) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let sz = item
|
||||
.get("size")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| item.get("lfs").and_then(|l| l.get("size")).and_then(|v| v.as_u64()))
|
||||
.unwrap_or(0);
|
||||
if sz == 0 {
|
||||
continue;
|
||||
}
|
||||
if path.to_ascii_lowercase().ends_with(".cmf") {
|
||||
single_cmf.push((path.to_string(), sz));
|
||||
} else if path.contains('/') && path.starts_with("parts-") {
|
||||
let top = path.split('/').next().unwrap_or(path).to_string();
|
||||
*dir_sizes.entry(top).or_default() += sz;
|
||||
}
|
||||
}
|
||||
if !single_cmf.is_empty() {
|
||||
return single_cmf;
|
||||
}
|
||||
let mut out: Vec<(String, u64)> = dir_sizes.into_iter().collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
/// `Content-Length` of a download URL (redirects followed) — the fallback total
|
||||
/// when the repo tree didn't tell us how big the file is.
|
||||
async fn head_content_length(url: &str, token: Option<&str>) -> Option<u64> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.ok()?;
|
||||
let mut req = client.head(url).header("User-Agent", "cortiq-gateway");
|
||||
if let Some(t) = token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
let resp = req.send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
resp.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn format_bytes(n: u64) -> String {
|
||||
if n >= 1_000_000_000 {
|
||||
format!("{:.1} GB", n as f64 / 1_000_000_000.0)
|
||||
} else if n >= 1_000_000 {
|
||||
format!("{} MB", n / 1_000_000)
|
||||
} else if n >= 1_000 {
|
||||
format!("{} KB", n / 1_000)
|
||||
} else {
|
||||
format!("{n} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn url_escape(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
@@ -263,6 +510,9 @@ fn sanitize(s: &str) -> String {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn sanitize_for_id(s: &str) -> String {
|
||||
sanitize(&s.to_lowercase())
|
||||
}
|
||||
|
||||
/// Kick off a conversion. Returns the job id immediately; progress streams
|
||||
/// Map the UI quant label to the native `cortiq` quant flag.
|
||||
@@ -293,26 +543,44 @@ async fn download_cmf_repo(
|
||||
store: Arc<JobStore>,
|
||||
id: String,
|
||||
cancel: Arc<Notify>,
|
||||
variant: Option<String>,
|
||||
) {
|
||||
store.set_progress(&id, 0.0, "listing files".into());
|
||||
// don't show 0% "listing files" — it looks stuck; show indeterminate "preparing"
|
||||
store.set_phase(&id, "preparing".into());
|
||||
|
||||
let tree_url = format!("https://huggingface.co/api/models/{repo}/tree/main?recursive=true");
|
||||
let client = reqwest::Client::new();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
let mut req = client.get(&tree_url).header("User-Agent", "cortiq-gateway");
|
||||
if let Some(t) = &hf_token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
|
||||
let mut cmf_rel_path = None;
|
||||
let mut cmf_files: Vec<(String, u64)> = Vec::new();
|
||||
let mut sharded_dirs: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
||||
let mut dir_sizes: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
|
||||
if let Ok(resp) = req.send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(arr) = json.as_array() {
|
||||
for item in arr {
|
||||
if item.get("type").and_then(|t| t.as_str()) != Some("file") {
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = item.get("path").and_then(|p| p.as_str()) {
|
||||
let sz = item
|
||||
.get("size")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| item.get("lfs").and_then(|l| l.get("size")).and_then(|v| v.as_u64()))
|
||||
.unwrap_or(0);
|
||||
if path.to_ascii_lowercase().ends_with(".cmf") {
|
||||
cmf_rel_path = Some(path.to_string());
|
||||
break;
|
||||
cmf_files.push((path.to_string(), sz));
|
||||
} else if path.contains('/') && path.starts_with("parts-") {
|
||||
let top = path.split('/').next().unwrap_or(path).to_string();
|
||||
sharded_dirs.entry(top.clone()).or_default().push(path.to_string());
|
||||
*dir_sizes.entry(top).or_default() += sz;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,12 +589,147 @@ async fn download_cmf_repo(
|
||||
}
|
||||
}
|
||||
|
||||
let cmf_path = match cmf_rel_path {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let repo_name = repo.rsplit('/').next().unwrap_or(&repo);
|
||||
format!("{repo_name}.cmf")
|
||||
// Decide what to download: single .cmf file vs sharded variant directory
|
||||
let is_sharded = !sharded_dirs.is_empty();
|
||||
let chosen_variant = variant.as_deref().map(|v| v.trim()).filter(|v| !v.is_empty());
|
||||
|
||||
if is_sharded {
|
||||
// pick variant: explicit > first alphabetically
|
||||
let target_dir = if let Some(v) = chosen_variant {
|
||||
if sharded_dirs.contains_key(v) { v.to_string() } else { sharded_dirs.keys().next().cloned().unwrap_or_default() }
|
||||
} else {
|
||||
// prefer smallest? use first sorted
|
||||
let mut keys: Vec<String> = sharded_dirs.keys().cloned().collect();
|
||||
keys.sort();
|
||||
keys.into_iter().next().unwrap_or_default()
|
||||
};
|
||||
let files = sharded_dirs.get(&target_dir).cloned().unwrap_or_default();
|
||||
if files.is_empty() {
|
||||
store.push_line(&id, format!("✗ variant '{target_dir}' not found"));
|
||||
store.finish(&id, false);
|
||||
store.drop_cancel(&id);
|
||||
return;
|
||||
}
|
||||
let total_sz = dir_sizes.get(&target_dir).copied().unwrap_or(0);
|
||||
let q = if target_dir.contains("q2tp") { "Q2TP" } else if target_dir.contains("q4tp") { "Q4TP" } else { "READY_CMF" };
|
||||
store.update_quant(&id, q.to_string());
|
||||
store.push_line(&id, format!("→ downloading {} ({} files, {}) from HuggingFace...", target_dir, files.len(), format_bytes(total_sz)));
|
||||
store.set_total(&id, total_sz);
|
||||
store.set_progress(&id, 0.001, "downloading".into());
|
||||
|
||||
// output is a directory: ensure {base}.cmf is a directory
|
||||
let out_dir = std::path::Path::new(&output_abs);
|
||||
// Drop a previous copy first (re-download / update). There is no resume,
|
||||
// so it would be overwritten anyway — and leaving it there would make the
|
||||
// size poller read the old bytes and report 100% before we transfer any.
|
||||
cleanup_output(&output_abs);
|
||||
// if output ends with .cmf and variant is sharded, keep it as dir (e.g. model.cmf/parts-*)
|
||||
// create base dir
|
||||
let _ = std::fs::create_dir_all(out_dir);
|
||||
// live percentage from bytes landing on disk (curl runs quiet, per part)
|
||||
let _poller = spawn_size_poller(store.clone(), id.clone(), output_abs.clone());
|
||||
// download each part file
|
||||
for (idx, rel) in files.iter().enumerate() {
|
||||
let filename = rel.rsplit('/').next().unwrap_or(rel);
|
||||
let dest = out_dir.join(filename);
|
||||
let url = format!("https://huggingface.co/{repo}/resolve/main/{rel}");
|
||||
store.push_line(&id, format!(" [{}/{}] {filename}", idx + 1, files.len()));
|
||||
let mut cmd = tokio::process::Command::new("curl");
|
||||
cmd.arg("-f").arg("-L").arg("-s").arg("-S").arg("-o").arg(&dest).arg(&url);
|
||||
if let Some(t) = &hf_token {
|
||||
cmd.arg("-H").arg(format!("Authorization: Bearer {t}"));
|
||||
}
|
||||
cmd.stdout(Stdio::null());
|
||||
cmd.stderr(Stdio::piped());
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
store.push_line(&id, format!("✗ failed {filename}: {e}"));
|
||||
cleanup_output(&output_abs);
|
||||
store.finish(&id, false);
|
||||
store.drop_cancel(&id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let t_err = child.stderr.take().map(|s| {
|
||||
let st = store.clone();
|
||||
let jid = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(s).lines();
|
||||
while let Ok(Some(l)) = lines.next_line().await {
|
||||
let l = l.trim().to_string();
|
||||
if !l.is_empty() {
|
||||
st.push_line(&jid, l);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
// Race the part against a cancel — a single part can be many GB, so
|
||||
// waiting for it to finish first would make Cancel look dead.
|
||||
let status = tokio::select! {
|
||||
r = child.wait() => {
|
||||
if let Some(t) = t_err { t.abort(); }
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
_ = cancel.notified() => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
if let Some(t) = t_err { t.abort(); }
|
||||
cleanup_output(&output_abs);
|
||||
store.push_line(&id, "✗ cancelled by user — partial output removed".into());
|
||||
store.set_state(&id, "cancelled");
|
||||
store.drop_cancel(&id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
// exact byte count after each part (the poller covers the gaps)
|
||||
let on_disk = path_size(out_dir);
|
||||
if total_sz > 0 {
|
||||
store.set_bytes(&id, on_disk);
|
||||
} else {
|
||||
let frac = (idx + 1) as f32 / files.len() as f32;
|
||||
store.set_progress(&id, frac, "downloading".into());
|
||||
}
|
||||
}
|
||||
Ok(s) => {
|
||||
store.push_line(&id, format!("✗ failed {filename}: exit {:?}", s.code()));
|
||||
cleanup_output(&output_abs);
|
||||
store.finish(&id, false);
|
||||
store.drop_cancel(&id);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
store.push_line(&id, format!("✗ failed {filename}: {e}"));
|
||||
cleanup_output(&output_abs);
|
||||
store.finish(&id, false);
|
||||
store.drop_cancel(&id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
store.push_line(&id, "✓ done".into());
|
||||
store.finish(&id, true);
|
||||
store.drop_cancel(&id);
|
||||
return;
|
||||
}
|
||||
|
||||
// single-file path (original logic)
|
||||
let (cmf_path, cmf_size) = if !cmf_files.is_empty() {
|
||||
// if variant specified and matches a file prefix, prefer it
|
||||
if let Some(v) = chosen_variant {
|
||||
cmf_files
|
||||
.iter()
|
||||
.find(|(p, _)| p.contains(v))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| cmf_files[0].clone())
|
||||
} else {
|
||||
cmf_files[0].clone()
|
||||
}
|
||||
} else {
|
||||
let repo_name = repo.rsplit('/').next().unwrap_or(&repo);
|
||||
(format!("{repo_name}.cmf"), 0)
|
||||
};
|
||||
|
||||
let path_lower = cmf_path.to_ascii_lowercase();
|
||||
@@ -344,13 +747,33 @@ async fn download_cmf_repo(
|
||||
};
|
||||
store.update_quant(&id, detected_quant);
|
||||
|
||||
store.push_line(&id, format!("→ downloading {cmf_path} from HuggingFace..."));
|
||||
store.set_progress(&id, 0.05, "downloading".into());
|
||||
|
||||
let download_url = format!("https://huggingface.co/{repo}/resolve/main/{cmf_path}");
|
||||
// The tree listing is the usual source of the size; ask the CDN directly when
|
||||
// it wasn't (private repo, tree fetch failed) so the bar still gets a total.
|
||||
let total = if cmf_size > 0 {
|
||||
cmf_size
|
||||
} else {
|
||||
head_content_length(&download_url, hf_token.as_deref())
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
};
|
||||
store.push_line(&id, {
|
||||
let sz = if total > 0 { format!(" ({})", format_bytes(total)) } else { String::new() };
|
||||
format!("→ downloading {cmf_path}{sz} from HuggingFace...")
|
||||
});
|
||||
store.set_total(&id, total);
|
||||
store.set_progress(&id, 0.001, "downloading".into());
|
||||
// Drop a previous copy first (re-download / update). There is no resume, so
|
||||
// it would be overwritten anyway — and leaving it there would make the size
|
||||
// poller read the old bytes and report 100% before we transfer any.
|
||||
cleanup_output(&output_abs);
|
||||
// Progress comes from bytes on disk only. curl's own meter is worse than
|
||||
// useless here: with -L it prints a complete 0→100% bar for the redirect hop
|
||||
// before the real transfer starts, which used to pin the job at 100%.
|
||||
let _poller = spawn_size_poller(store.clone(), id.clone(), output_abs.clone());
|
||||
|
||||
let mut cmd = tokio::process::Command::new("curl");
|
||||
cmd.arg("-f").arg("-L").arg("-#").arg("-o").arg(&output_abs);
|
||||
cmd.arg("-f").arg("-L").arg("-s").arg("-S").arg("-o").arg(&output_abs);
|
||||
|
||||
if let Some(t) = &hf_token {
|
||||
cmd.arg("-H").arg(format!("Authorization: Bearer {t}"));
|
||||
@@ -370,27 +793,19 @@ async fn download_cmf_repo(
|
||||
}
|
||||
};
|
||||
|
||||
// -sS leaves stderr for real errors only — drain it into the log so a failure
|
||||
// says what went wrong instead of just an exit code.
|
||||
let stderr = child.stderr.take();
|
||||
let mut t_err = None;
|
||||
if let Some(err_stream) = stderr {
|
||||
let store_clone = store.clone();
|
||||
let id_clone = id.clone();
|
||||
t_err = Some(tokio::spawn(async move {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut reader = err_stream;
|
||||
let mut buf = [0u8; 256];
|
||||
while let Ok(n) = reader.read(&mut buf).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&buf[..n]);
|
||||
for word in text.split_whitespace() {
|
||||
if let Some(num_str) = word.strip_suffix('%') {
|
||||
if let Ok(pct) = num_str.parse::<f32>() {
|
||||
let frac = (pct / 100.0).clamp(0.0, 1.0);
|
||||
store_clone.set_progress(&id_clone, frac, "downloading".into());
|
||||
}
|
||||
}
|
||||
let mut lines = BufReader::new(err_stream).lines();
|
||||
while let Ok(Some(l)) = lines.next_line().await {
|
||||
let l = l.trim().to_string();
|
||||
if !l.is_empty() {
|
||||
store_clone.push_line(&id_clone, l);
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -484,11 +899,20 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
cfg.models_dir
|
||||
)
|
||||
})?;
|
||||
let base = if p.name.trim().is_empty() {
|
||||
let mut base = if p.name.trim().is_empty() {
|
||||
sanitize(p.repo.rsplit('/').next().unwrap_or(&p.repo))
|
||||
} else {
|
||||
sanitize(&p.name)
|
||||
};
|
||||
// for sharded variants make output distinct (e.g. DeepSeek parts-q2tp vs q4tp); single-file CMF ignores variant
|
||||
if let Some(v) = p.variant.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||
if v.starts_with("parts-") {
|
||||
let vs = sanitize(v);
|
||||
if !base.ends_with(&vs) {
|
||||
base = format!("{base}-{vs}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = std::path::Path::new(&cfg.models_dir)
|
||||
.join(format!("{base}.cmf"))
|
||||
.to_string_lossy()
|
||||
@@ -497,7 +921,13 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
.map(|d| d.join(format!("{base}.cmf")).to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| output.clone());
|
||||
|
||||
let id = gen_id(&p.repo);
|
||||
let id = gen_id(&format!("{}::{}", p.repo, p.variant.as_deref().unwrap_or("")));
|
||||
// if same repo/variant is already running, don't reset progress — just return it
|
||||
if let Some(existing) = store.get(&id) {
|
||||
if existing.state == "running" {
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
let is_cmf = is_cmf_repo(&p.repo);
|
||||
let quant_display = if is_cmf {
|
||||
let r = p.repo.to_lowercase();
|
||||
@@ -524,6 +954,13 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
repo: p.repo.clone(),
|
||||
quant: quant_display.clone(),
|
||||
output: output_abs.clone(),
|
||||
name: base.clone(),
|
||||
variant: p
|
||||
.variant
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
state: "running".into(),
|
||||
log: if is_cmf {
|
||||
vec![format!(
|
||||
@@ -538,6 +975,8 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
size_bytes: None,
|
||||
progress: Some(0.0),
|
||||
phase: Some("starting".into()),
|
||||
total_bytes: None,
|
||||
done_bytes: None,
|
||||
});
|
||||
let cancel = Arc::new(Notify::new());
|
||||
store.set_cancel(&id, cancel.clone());
|
||||
@@ -550,8 +989,9 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
|
||||
if is_cmf && !use_python {
|
||||
let ret_id = id.clone();
|
||||
let variant = p.variant.clone();
|
||||
tokio::spawn(async move {
|
||||
download_cmf_repo(p.repo, output_abs, hf_token, store, id, cancel).await;
|
||||
download_cmf_repo(p.repo, output_abs, hf_token, store, id, cancel, variant).await;
|
||||
});
|
||||
return Ok(ret_id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user