[feat] integrate global progress manager for unified log handling; enhance model download workflow with progress tracking and SHA-256 verification

This commit is contained in:
2025-08-12 03:46:39 +02:00
parent 9120e8fb26
commit 37c43161da
4 changed files with 304 additions and 16 deletions

View File

@@ -179,7 +179,8 @@ where
#[macro_export] #[macro_export]
macro_rules! elog { macro_rules! elog {
($($arg:tt)*) => {{ ($($arg:tt)*) => {{
eprintln!("ERROR: {}", format!($($arg)*)); // Route errors through the progress area when available so they render inside cliclack
$crate::log_with_level!("ERROR", None, true, $($arg)*);
}} }}
} }
/// Internal helper macro used by other logging macros to centralize the /// Internal helper macro used by other logging macros to centralize the
@@ -196,7 +197,11 @@ macro_rules! log_with_level {
!$crate::is_quiet() !$crate::is_quiet()
}; };
if should_print { if should_print {
eprintln!("{}: {}", $label, format!($($arg)*)); let line = format!("{}: {}", $label, format!($($arg)*));
// Try to render via the active progress manager (cliclack/indicatif area).
if !$crate::progress::log_line_via_global(&line) {
eprintln!("{}", line);
}
} }
}} }}
} }

View File

@@ -291,9 +291,27 @@ fn run() -> Result<()> {
} }
} }
// Handle model management modes early and exit
if args.download_models && args.update_models {
// Avoid ambiguous behavior when both flags are set
return Err(anyhow!("Choose only one: --download-models or --update-models"));
}
if args.download_models {
// Launch interactive model downloader and exit
polyscribe::models::run_interactive_model_downloader()?;
return Ok(());
}
if args.update_models {
// Update existing local models and exit
polyscribe::models::update_local_models()?;
return Ok(());
}
// Prefer Config-driven progress factory // Prefer Config-driven progress factory
let pf = ProgressFactory::from_config(&config); let pf = ProgressFactory::from_config(&config);
let pm = pf.make_manager(pf.decide_mode(args.inputs.len())); let pm = pf.make_manager(pf.decide_mode(args.inputs.len()));
// Route subsequent INFO/WARN/DEBUG logs through the cliclack/indicatif area
polyscribe::progress::set_global_progress_manager(&pm);
// Determine formats // Determine formats
let out_formats = if args.out_format.is_empty() { let out_formats = if args.out_format.is_empty() {
@@ -313,7 +331,8 @@ fn run() -> Result<()> {
let do_merge = args.merge || args.merge_and_separate; let do_merge = args.merge || args.merge_and_separate;
if polyscribe::verbose_level() >= 1 && !args.quiet { if polyscribe::verbose_level() >= 1 && !args.quiet {
eprintln!("Mode: {}", if do_merge { "merge" } else { "separate" }); // Render mode information inside the progress/cliclack area
polyscribe::ilog!("Mode: {}", if do_merge { "merge" } else { "separate" });
} }
// Collect inputs and default speakers // Collect inputs and default speakers
@@ -459,12 +478,13 @@ fn run() -> Result<()> {
// Emit totals and summary to stderr unless quiet // Emit totals and summary to stderr unless quiet
if !polyscribe::is_quiet() { if !polyscribe::is_quiet() {
eprintln!("INFO: Total: {}/{} processed", summary.len(), plan.len()); // Print inside the progress/cliclack area
eprintln!("Summary:"); polyscribe::ilog!("Total: {}/{} processed", summary.len(), plan.len());
for line in render_summary_lines(&summary) { eprintln!("{}", line); } polyscribe::ilog!("Summary:");
for (_, _, ok, _) in &summary { if !ok { eprintln!("ERR"); } } for line in render_summary_lines(&summary) { polyscribe::ilog!("{}", line); }
eprintln!(); for (_, _, ok, _) in &summary { if !ok { polyscribe::elog!("ERR"); } }
if had_error { eprintln!("ERROR: One or more inputs failed"); } polyscribe::ilog!("");
if had_error { polyscribe::elog!("One or more inputs failed"); }
} }
if had_error { std::process::exit(2); } if had_error { std::process::exit(2); }

View File

@@ -440,11 +440,26 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntr
&[], &[],
)?; )?;
// Map labels back to entries in stable order // If no variants were explicitly selected, ask for confirmation to download all.
// This avoids surprising behavior while still allowing a quick "download all" path.
let mut picked: Vec<ModelEntry> = Vec::new(); let mut picked: Vec<ModelEntry> = Vec::new();
for (i, label) in labels.iter().enumerate() { if selected_labels.is_empty() {
if selected_labels.iter().any(|s| s == label) { // Confirm with the user; default to "No" to prevent accidental bulk downloads.
picked.push(variants[i].clone().clone()); if crate::ui::prompt_confirm(&format!("No variants selected. Download ALL {base} variants?"), false).unwrap_or(false) {
crate::qlog!("Downloading all {base} variants as requested.");
for v in &variants {
picked.push((*v).clone());
}
} else {
// User declined; return empty selection so caller can abort gracefully.
return Ok(Vec::new());
}
} else {
// Map labels back to entries in stable order
for (i, label) in labels.iter().enumerate() {
if selected_labels.iter().any(|s| s == label) {
picked.push(variants[i].clone());
}
} }
} }
@@ -480,6 +495,11 @@ pub fn run_interactive_model_downloader() -> Result<()> {
.build() .build()
.context("Failed to build HTTP client")?; .context("Failed to build HTTP client")?;
// Set up a temporary progress manager so INFO/WARN logs render within the UI.
let pf0 = crate::progress::ProgressFactory::from_config(&crate::Config::from_globals());
let pm0 = pf0.make_manager(crate::progress::ProgressMode::Single);
crate::progress::set_global_progress_manager(&pm0);
ilog!( ilog!(
"Fetching online data: contacting Hugging Face to retrieve available models (this may take a moment)..." "Fetching online data: contacting Hugging Face to retrieve available models (this may take a moment)..."
); );
@@ -493,11 +513,162 @@ pub fn run_interactive_model_downloader() -> Result<()> {
qlog!("No selection. Aborting download."); qlog!("No selection. Aborting download.");
return Ok(()); return Ok(());
} }
// Set up progress bars for downloads
let pf = crate::progress::ProgressFactory::from_config(&crate::Config::from_globals());
let pm = pf.make_manager(crate::progress::ProgressMode::Multi { total_inputs: selected.len() as u64 });
crate::progress::set_global_progress_manager(&pm);
// Install Ctrl-C cleanup to ensure partial downloads (*.part) are removed on cancel
crate::progress::install_ctrlc_cleanup(pm.clone());
pm.set_total(selected.len());
for m in selected { for m in selected {
if let Err(e) = download_one_model(&client, models_dir, &m) { let label = format!("{} ({} total)", m.name, human_size(m.size));
let item = pm.start_item(&label);
// Initialize message
if m.size > 0 { update_item_progress(&item, 0, m.size); }
if let Err(e) = download_one_model_with_progress(&client, models_dir, &m, &item) {
item.finish_with("done");
elog!("Error: {:#}", e); elog!("Error: {:#}", e);
} }
pm.inc_completed();
} }
pm.finish_all();
Ok(())
}
/// Internal helper: update a per-item progress handle with bytes progress.
fn update_item_progress(item: &crate::progress::ItemHandle, done_bytes: u64, total_bytes: u64) {
let total_mib = (total_bytes as f64) / (1024.0 * 1024.0);
let done_mib = (done_bytes as f64) / (1024.0 * 1024.0);
let pct = if total_bytes > 0 { ((done_bytes as f64) * 100.0 / (total_bytes as f64)).round() } else { 0.0 };
item.set_message(&format!("{:.2}/{:.2} MiB ({:.0}%)", done_mib, total_mib, pct));
if total_bytes > 0 {
item.set_progress((done_bytes as f32) / (total_bytes as f32));
}
}
/// Internal streaming helper used by both network and tests.
fn stream_with_progress<R: Read, W: Write>(mut reader: R, mut writer: W, total: u64, item: &crate::progress::ItemHandle) -> Result<(u64, String)> {
let mut hasher = Sha256::new();
let mut buf = [0u8; 1024 * 128];
let mut done: u64 = 0;
if total > 0 {
// initialize bar to determinate length 100
item.set_progress(0.0);
}
loop {
let n = reader.read(&mut buf).context("Network/read error")?;
if n == 0 { break; }
hasher.update(&buf[..n]);
writer.write_all(&buf[..n]).context("Write error")?;
done += n as u64;
update_item_progress(item, done, total);
}
writer.flush().ok();
let got = to_hex_lower(&hasher.finalize());
Ok((done, got))
}
/// Download a single model entry into the given models directory, verifying SHA-256 when available, with visible progress.
fn download_one_model_with_progress(client: &Client, models_dir: &Path, entry: &ModelEntry, item: &crate::progress::ItemHandle) -> Result<()> {
let final_path = models_dir.join(format!("ggml-{}.bin", entry.name));
// Same pre-checks as the non-progress version (up-to-date checks)
if final_path.exists() {
if let Some(expected) = &entry.sha256 {
match compute_file_sha256_hex(&final_path) {
Ok(local_hash) => {
if local_hash.eq_ignore_ascii_case(expected) {
item.set_message(&format!("{} up-to-date", entry.name));
item.set_progress(1.0);
item.finish_with("done");
return Ok(());
}
}
Err(_) => { /* proceed to download */ }
}
} else if entry.size > 0 {
if let Ok(md) = std::fs::metadata(&final_path) {
if md.len() == entry.size {
item.set_message(&format!("{} up-to-date", entry.name));
item.set_progress(1.0);
item.finish_with("done");
return Ok(());
}
}
}
}
// Offline/local copy mode for tests (same behavior, but reflect via item)
if let Ok(base_dir) = env::var("POLYSCRIBE_MODELS_BASE_COPY_DIR") {
let src_path = std::path::Path::new(&base_dir).join(format!("ggml-{}.bin", entry.name));
if src_path.exists() {
let tmp_path = models_dir.join(format!("ggml-{}.bin.part", entry.name));
if tmp_path.exists() { let _ = std::fs::remove_file(&tmp_path); }
std::fs::copy(&src_path, &tmp_path).with_context(|| {
format!("Failed to copy from {} to {}", src_path.display(), tmp_path.display())
})?;
if let Some(expected) = &entry.sha256 {
let got = compute_file_sha256_hex(&tmp_path)?;
if !got.eq_ignore_ascii_case(expected) {
let _ = std::fs::remove_file(&tmp_path);
return Err(anyhow!("SHA-256 mismatch for {} (copied): expected {}, got {}", entry.name, expected, got));
}
}
if final_path.exists() { let _ = std::fs::remove_file(&final_path); }
std::fs::rename(&tmp_path, &final_path).with_context(|| format!("Failed to move into place: {}", final_path.display()))?;
item.set_progress(1.0);
item.finish_with("done");
return Ok(());
}
}
let url = format!(
"https://huggingface.co/{}/resolve/main/ggml-{}.bin",
entry.repo, entry.name
);
let mut resp = client
.get(url)
.send()
.and_then(|r| r.error_for_status())
.context("Failed to download model")?;
let tmp_path = models_dir.join(format!("ggml-{}.bin.part", entry.name));
if tmp_path.exists() { let _ = std::fs::remove_file(&tmp_path); }
let mut file = std::io::BufWriter::new(
File::create(&tmp_path).with_context(|| format!("Failed to create {}", tmp_path.display()))?,
);
// Determine total bytes (prefer metadata/HEAD-derived entry.size)
let total = if entry.size > 0 { entry.size } else { resp.content_length().unwrap_or(0) };
// Stream with progress
let (_bytes, hash_hex) = stream_with_progress(&mut resp, &mut file, total, item)?;
// Verify
item.set_message("sha256 verifying…");
if let Some(expected) = &entry.sha256 {
if hash_hex.to_lowercase() != expected.to_lowercase() {
let _ = std::fs::remove_file(&tmp_path);
return Err(anyhow!(
"SHA-256 mismatch for {}: expected {}, got {}",
entry.name,
expected,
hash_hex
));
}
} else {
qlog!(
"Warning: no SHA-256 available for {}. Skipping verification.",
entry.name
);
}
// Replace existing file safely
if final_path.exists() { let _ = std::fs::remove_file(&final_path); }
std::fs::rename(&tmp_path, &final_path)
.with_context(|| format!("Failed to move into place: {}", final_path.display()))?;
item.finish_with("done");
Ok(()) Ok(())
} }
@@ -701,6 +872,11 @@ pub fn update_local_models() -> Result<()> {
.build() .build()
.context("Failed to build HTTP client")?; .context("Failed to build HTTP client")?;
// Ensure logs go through cliclack area during update as well
let pf_up = crate::progress::ProgressFactory::from_config(&crate::Config::from_globals());
let pm_up = pf_up.make_manager(crate::progress::ProgressMode::Single);
crate::progress::set_global_progress_manager(&pm_up);
// Obtain manifest: env override or online fetch // Obtain manifest: env override or online fetch
let models: Vec<ModelEntry> = if let Ok(manifest_path) = env::var("POLYSCRIBE_MODELS_MANIFEST") let models: Vec<ModelEntry> = if let Ok(manifest_path) = env::var("POLYSCRIBE_MODELS_MANIFEST")
{ {
@@ -1071,4 +1247,31 @@ mod tests {
std::env::remove_var("HOME"); std::env::remove_var("HOME");
} }
} }
#[test]
fn test_download_progress_bar_reaches_done() {
use std::io::Cursor;
// Prepare small fake stream of 300 KiB
let data = vec![42u8; 300 * 1024];
let total = data.len() as u64;
let cursor = Cursor::new(data);
let mut sink: Vec<u8> = Vec::new();
let pm = crate::progress::ProgressManager::new_for_tests_multi_hidden(1);
let item = pm.start_item("test-download");
// Stream into sink while updating progress
let (_bytes, _hash) = super::stream_with_progress(cursor, &mut sink, total, &item).unwrap();
// Transition to verifying and finish
item.set_message("sha256 verifying…");
item.finish_with("done");
// Inspect current bar state
if let Some((pos, len, finished, msg)) = pm.current_state_for_tests() {
// Ensure determinate length is 100 and we reached 100
assert_eq!(len, 100);
assert_eq!(pos, 100);
assert!(finished);
assert!(msg.contains("done"));
} else {
panic!("progress manager did not expose current state");
}
}
} }

View File

@@ -8,6 +8,35 @@ use std::time::Instant;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
// Global hook to route logs through the active progress manager so they render within
// the same cliclack/indicatif area instead of raw stderr.
static GLOBAL_PM: std::sync::Mutex<Option<ProgressManager>> = std::sync::Mutex::new(None);
/// Install a global ProgressManager used for printing log lines above bars.
pub fn set_global_progress_manager(pm: &ProgressManager) {
if let Ok(mut g) = GLOBAL_PM.lock() {
*g = Some(pm.clone());
}
}
/// Remove the global ProgressManager hook.
pub fn clear_global_progress_manager() {
if let Ok(mut g) = GLOBAL_PM.lock() {
*g = None;
}
}
/// Try to print a line via the global ProgressManager, returning true if handled.
pub fn log_line_via_global(line: &str) -> bool {
if let Ok(g) = GLOBAL_PM.lock() {
if let Some(pm) = g.as_ref() {
pm.println_above_bars(line);
return true;
}
}
false
}
const NAME_WIDTH: usize = 28; const NAME_WIDTH: usize = 28;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -212,6 +241,25 @@ impl ProgressManager {
} }
} }
/// Test helper: get state of the current item bar (position, length, finished, message).
pub fn current_state_for_tests(&self) -> Option<(u64, u64, bool, String)> {
match &self.inner {
ProgressInner::Single(s) => Some((
s.current.position(),
s.current.length().unwrap_or(0),
s.current.is_finished(),
s.current.message().to_string(),
)),
ProgressInner::Multi(m) => Some((
m.current.position(),
m.current.length().unwrap_or(0),
m.current.is_finished(),
m.current.message().to_string(),
)),
ProgressInner::Noop => None,
}
}
fn noop() -> Self { fn noop() -> Self {
Self { Self {
inner: ProgressInner::Noop, inner: ProgressInner::Noop,
@@ -501,7 +549,7 @@ pub fn select_mode(si: SelectionInput) -> (bool, ProgressMode) {
(enabled, mode) (enabled, mode)
} }
/// Optional Ctrl-C cleanup: clears progress bars and removes .last_model before exiting on SIGINT. /// Optional Ctrl-C cleanup: clears progress bars and removes temporary files before exiting on SIGINT.
pub fn install_ctrlc_cleanup(pm: ProgressManager) { pub fn install_ctrlc_cleanup(pm: ProgressManager) {
let state = Arc::new(Mutex::new(Some(pm.clone()))); let state = Arc::new(Mutex::new(Some(pm.clone())));
let state_clone = state.clone(); let state_clone = state.clone();
@@ -513,8 +561,20 @@ pub fn install_ctrlc_cleanup(pm: ProgressManager) {
} }
} }
// Best-effort removal of the last-model cache so it doesn't persist after Ctrl-C // Best-effort removal of the last-model cache so it doesn't persist after Ctrl-C
let last_path = crate::models_dir_path().join(".last_model"); let models_dir = crate::models_dir_path();
let last_path = models_dir.join(".last_model");
let _ = std::fs::remove_file(&last_path); let _ = std::fs::remove_file(&last_path);
// Also remove any unfinished model downloads ("*.part")
if let Ok(rd) = std::fs::read_dir(&models_dir) {
for entry in rd.flatten() {
let p = entry.path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".part") {
let _ = std::fs::remove_file(&p);
}
}
}
}
// Exit with 130 to reflect SIGINT // Exit with 130 to reflect SIGINT
std::process::exit(130); std::process::exit(130);
}) { }) {