Add HuggingFace repo cloning and project workspace support
- Add hf_clone.rs for HuggingFace repo inspection and cloning - Add project.rs for project workspace management and artifact tracking - Update main.rs and ui.rs to integrate new modules and screens - Minor fix in finetune.rs for optimizer variable cloning
keypair34 committed
Apr 23, 2026 at 05:35 UTC
78665b668dd94dc7efde57a46b36e151fb52b390
6 files changed
+1265
-27
src/app.rs
+390
-24
@@ -44,6 +44,7 @@ pub enum Screen {
44
ModelDetail,
45
FineTune,
46
GgufDetail,
47
+ CloneRepo,
48
}
49
50
/// What kind of artifact was found on disk.
@@ -175,6 +176,7 @@ pub enum AuthEvent {
176
GgufProgress(crate::gguf::GgufProgress),
177
// HF upload
178
UploadProgress(crate::hf_upload::UploadProgress),
179
+ CloneProgress(crate::hf_clone::CloneProgress),
180
}
181
182
pub struct App {
@@ -245,6 +247,14 @@ pub struct App {
247
pub upload_running: bool,
248
pub upload_progress: Option<crate::hf_upload::UploadProgress>,
249
pub upload_repo_name: String,
250
+ // active project (set when entering fine-tune from CloneRepo)
251
+ pub current_project: Option<crate::project::OndeProject>,
252
+ // clone repo (check + base model selection)
253
+ pub clone_repo_id: String,
254
+ pub clone_repo_checking: bool,
255
+ pub clone_repo_status: Option<crate::hf_clone::RepoStatus>,
256
+ pub clone_base_cursor: usize,
257
+ pub clone_progress: Option<crate::hf_clone::CloneProgress>,
258
}
259
260
impl App {
@@ -285,10 +295,10 @@ impl App {
295
download_progress: None,
296
finetune_model_id: String::new(),
297
finetune_model_dir: String::new(),
288
- finetune_data_path: "~/.onde/finetune/train.jsonl".to_string(),
298
+ finetune_data_path: String::new(),
299
finetune_rank: "8".to_string(),
300
finetune_epochs: "3".to_string(),
291
- finetune_lr: "0.0001".to_string(),
301
+ finetune_lr: "0.00001".to_string(),
302
finetune_focus: FineTuneFocus::ModelDir,
303
finetune_running: false,
304
finetune_progress: None,
@@ -303,6 +313,12 @@ impl App {
313
upload_running: false,
314
upload_progress: None,
315
upload_repo_name: String::new(),
316
+ current_project: None,
317
+ clone_repo_id: String::new(),
318
+ clone_repo_checking: false,
319
+ clone_repo_status: None,
320
+ clone_base_cursor: 0,
321
+ clone_progress: None,
322
}
323
}
324
@@ -333,7 +349,8 @@ impl App {
349
| AuthEvent::HfSearchFailed(_)
350
| AuthEvent::MergeProgress(_)
351
| AuthEvent::GgufProgress(_)
336
- | AuthEvent::UploadProgress(_) => {}
352
+ | AuthEvent::UploadProgress(_)
353
+ | AuthEvent::CloneProgress(_) => {}
354
_ => {
355
self.busy = false;
356
}
@@ -554,6 +571,44 @@ impl App {
571
}
572
self.upload_progress = Some(progress);
573
}
574
+ AuthEvent::CloneProgress(progress) => {
575
+ match &progress {
576
+ crate::hf_clone::CloneProgress::RepoChecked(status) => {
577
+ self.clone_repo_checking = false;
578
+ self.clone_repo_status = Some(status.clone());
579
+ match status {
580
+ crate::hf_clone::RepoStatus::NotFound => {
581
+ self.status =
582
+ Status::neutral("Repo not found. Press Enter to create it.");
583
+ }
584
+ crate::hf_clone::RepoStatus::Empty { .. } => {
585
+ self.status = Status::neutral(
586
+ "Repo is empty — pick a base model to fine-tune.",
587
+ );
588
+ }
589
+ crate::hf_clone::RepoStatus::HasModel { .. } => {
590
+ self.status = Status::success("Repo already has model files.");
591
+ }
592
+ }
593
+ }
594
+ crate::hf_clone::CloneProgress::RepoReady => {
595
+ self.clone_repo_checking = false;
596
+ self.status =
597
+ Status::success("Repo created. Pick a base model to fine-tune.");
598
+ // After creating, set status to Empty so the UI shows base model picker
599
+ self.clone_repo_status = Some(crate::hf_clone::RepoStatus::Empty {
600
+ repo_id: self.clone_repo_id.clone(),
601
+ files: vec![],
602
+ });
603
+ }
604
+ crate::hf_clone::CloneProgress::Failed(msg) => {
605
+ self.clone_repo_checking = false;
606
+ self.status = Status::error(msg.clone());
607
+ }
608
+ _ => {}
609
+ }
610
+ self.clone_progress = Some(progress);
611
+ }
612
}
613
}
614
}
@@ -674,6 +729,7 @@ fn handle_key(
729
Screen::ModelDetail => handle_key_model_detail(app, key, tx),
730
Screen::GgufDetail => handle_key_gguf_detail(app, key, tx),
731
Screen::FineTune => handle_key_finetune(app, key, tx),
732
+ Screen::CloneRepo => handle_key_clone_repo(app, key, tx),
733
}
734
}
735
@@ -1021,6 +1077,15 @@ fn handle_key_downloads(
1077
));
1078
}
1079
}
1080
+ (Char('c'), KeyModifiers::NONE) => {
1081
+ app.clone_repo_id.clear();
1082
+ app.clone_repo_status = None;
1083
+ app.clone_base_cursor = 0;
1084
+ app.clone_repo_checking = false;
1085
+ app.clone_progress = None;
1086
+ app.screen = Screen::CloneRepo;
1087
+ app.status = Status::neutral("Enter a HuggingFace repo ID to check.");
1088
+ }
1089
(Char('/'), KeyModifiers::NONE) => {
1090
app.hf_search_active = true;
1091
app.hf_search_query.clear();
@@ -1074,17 +1139,22 @@ fn handle_key_model_detail(
1139
.is_some_and(|a| a.kind == ArtifactKind::Gguf) =>
1140
{
1141
if let Some(entry) = app.adapter_list.get(app.adapter_cursor) {
1077
- // Derive a default repo name from model + file
1078
- let base_name = app
1079
- .downloads
1080
- .get(app.downloads_cursor)
1081
- .map(|m| m.model_id.replace('/', "-"))
1082
- .unwrap_or_else(|| "finetuned-model".to_string());
1083
- let stem = entry
1084
- .file_name
1085
- .strip_suffix(".gguf")
1086
- .unwrap_or(&entry.file_name);
1087
- app.upload_repo_name = format!("ondeinference/{base_name}-{stem}");
1142
+ // Use the project repo ID when available (e.g. "ondeinference/rumi"),
1143
+ // otherwise derive from the base model name.
1144
+ app.upload_repo_name = if let Some(ref project) = app.current_project {
1145
+ project.repo_id.clone()
1146
+ } else {
1147
+ let base_name = app
1148
+ .downloads
1149
+ .get(app.downloads_cursor)
1150
+ .map(|m| m.model_id.replace('/', "-"))
1151
+ .unwrap_or_else(|| "finetuned-model".to_string());
1152
+ let stem = entry
1153
+ .file_name
1154
+ .strip_suffix(".gguf")
1155
+ .unwrap_or(&entry.file_name);
1156
+ format!("ondeinference/{base_name}-{stem}")
1157
+ };
1158
app.selected_gguf = Some(entry.clone());
1159
app.upload_progress = None;
1160
app.upload_running = false;
@@ -1140,6 +1210,8 @@ fn handle_key_model_detail(
1210
} else {
1211
app.finetune_model_id = model.model_id.clone();
1212
app.finetune_model_dir = resolved;
1213
+ app.finetune_data_path = finetune_data_path_for_model(&model.model_id);
1214
+ app.current_project = None; // no project context from model detail
1215
app.finetune_focus = FineTuneFocus::DataPath;
1216
app.finetune_running = false;
1217
app.finetune_progress = None;
@@ -1461,7 +1533,12 @@ fn handle_key_finetune(
1533
})
1534
.unwrap_or_else(|| raw.to_string())
1535
});
1464
- let output_dir = base_dir.parent().unwrap_or(&base_dir).join("merged-model");
1536
+ // Derive a timestamped merge dir from the adapter dir name.
1537
+ // Put the merged model in a `merged/` subdir next to the adapter
1538
+ let output_dir = adapter_path
1539
+ .parent()
1540
+ .unwrap_or_else(|| std::path::Path::new("."))
1541
+ .join("merged");
1542
1543
let merge_config = crate::merge::MergeConfig {
1544
base_dir,
@@ -1487,10 +1564,28 @@ fn handle_key_finetune(
1564
(Char('g'), KeyModifiers::NONE) if app.merged_model_dir.is_some() => {
1565
// Start GGUF export from merged model
1566
let model_dir = app.merged_model_dir.clone().unwrap_or_default();
1490
- let output_path = model_dir
1491
- .parent()
1492
- .unwrap_or(&model_dir)
1493
- .join("model-finetuned-q8_0.gguf");
1567
+ // Derive GGUF filename from the merged dir name for traceability.
1568
+ // Place the GGUF in the run dir (parent of merged/).
1569
+ let run_dir = model_dir.parent().unwrap_or(&model_dir);
1570
+ let gguf_name = app
1571
+ .current_project
1572
+ .as_ref()
1573
+ .map(|p| {
1574
+ p.repo_id
1575
+ .split('/')
1576
+ .next_back()
1577
+ .unwrap_or("model")
1578
+ .to_string()
1579
+ })
1580
+ .unwrap_or_else(|| {
1581
+ // Use the base model short name for traceability
1582
+ app.finetune_model_id
1583
+ .split('/')
1584
+ .next_back()
1585
+ .unwrap_or("model-finetuned")
1586
+ .to_string()
1587
+ });
1588
+ let output_path = run_dir.join(format!("{gguf_name}-q8_0.gguf"));
1589
1590
let gguf_config = crate::gguf::GgufConfig {
1591
model_dir,
@@ -1560,7 +1655,7 @@ fn handle_key_finetune(
1655
(Enter, _) => {
1656
let rank: usize = app.finetune_rank.parse().unwrap_or(8);
1657
let epochs: usize = app.finetune_epochs.parse().unwrap_or(3);
1563
- let lr: f64 = app.finetune_lr.parse().unwrap_or(0.0001);
1658
+ let lr: f64 = app.finetune_lr.parse().unwrap_or(0.00001);
1659
1660
// Expand ~ in paths
1661
let expand = |s: &str| -> String {
@@ -1574,10 +1669,23 @@ fn handle_key_finetune(
1669
1670
let model_dir = std::path::PathBuf::from(expand(&app.finetune_model_dir));
1671
let data_path = std::path::PathBuf::from(expand(&app.finetune_data_path));
1577
- let output_dir = model_dir
1578
- .parent()
1579
- .unwrap_or(&model_dir)
1580
- .join("lora-adapter");
1672
+ // Use the project's run directory if available, otherwise fall back
1673
+ // to a timestamped dir next to the model.
1674
+ let output_dir = if let Some(ref project) = app.current_project {
1675
+ match crate::project::new_run_dir(project) {
1676
+ Ok(run_dir) => run_dir,
1677
+ Err(e) => {
1678
+ app.status = Status::error(format!("Failed to create run dir: {e}"));
1679
+ return;
1680
+ }
1681
+ }
1682
+ } else {
1683
+ let ts = run_timestamp();
1684
+ model_dir
1685
+ .parent()
1686
+ .unwrap_or(&model_dir)
1687
+ .join(format!("lora-{ts}"))
1688
+ };
1689
1690
let config = crate::finetune::FineTuneConfig {
1691
model_dir,
@@ -1632,6 +1740,264 @@ fn handle_key_finetune(
1740
}
1741
}
1742
1743
+fn handle_key_clone_repo(
1744
+ app: &mut App,
1745
+ key: crossterm::event::KeyEvent,
1746
+ tx: mpsc::UnboundedSender<AuthEvent>,
1747
+) {
1748
+ use KeyCode::*;
1749
+
1750
+ // While checking, only allow quit.
1751
+ if app.clone_repo_checking {
1752
+ if matches!(
1753
+ (key.code, key.modifiers),
1754
+ (Char('c'), KeyModifiers::CONTROL)
1755
+ ) {
1756
+ app.should_quit = true;
1757
+ }
1758
+ return;
1759
+ }
1760
+
1761
+ // If repo is empty or not found + created → show base model picker
1762
+ let is_picking_base = matches!(
1763
+ &app.clone_repo_status,
1764
+ Some(crate::hf_clone::RepoStatus::Empty { .. })
1765
+ );
1766
+
1767
+ if is_picking_base {
1768
+ match (key.code, key.modifiers) {
1769
+ (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1770
+ app.clone_base_cursor = app.clone_base_cursor.saturating_sub(1);
1771
+ }
1772
+ (Down, _) | (Char('j'), KeyModifiers::NONE) => {
1773
+ if app.clone_base_cursor + 1 < crate::hf_clone::BASE_MODELS.len() {
1774
+ app.clone_base_cursor += 1;
1775
+ }
1776
+ }
1777
+ (Enter, _) => {
1778
+ // User selected a base model — download it then go to fine-tune
1779
+ let base = &crate::hf_clone::BASE_MODELS[app.clone_base_cursor];
1780
+ let base_repo_id = base.repo_id.to_string();
1781
+
1782
+ // Check if the base model is already downloaded locally
1783
+ let resolved = resolve_hf_cache_path(&base_repo_id);
1784
+ if !resolved.is_empty()
1785
+ && std::path::Path::new(&resolved).join("config.json").exists()
1786
+ {
1787
+ // Create (or load) the project workspace for this repo
1788
+ let target_repo = app.clone_repo_id.clone();
1789
+ match crate::project::create_project(&target_repo, &base_repo_id) {
1790
+ Ok(mut project) => {
1791
+ project.base_model_dir = resolved.clone();
1792
+ let dataset_display =
1793
+ project.dataset_path.to_string_lossy().to_string();
1794
+ // Try to shorten the display path with ~/
1795
+ let dataset_display = if let Some(home) = dirs::home_dir() {
1796
+ let home_str = home.to_string_lossy();
1797
+ if dataset_display.starts_with(home_str.as_ref()) {
1798
+ format!("~{}", &dataset_display[home_str.len()..])
1799
+ } else {
1800
+ dataset_display
1801
+ }
1802
+ } else {
1803
+ dataset_display
1804
+ };
1805
+ app.finetune_data_path = dataset_display;
1806
+ app.finetune_model_id = base_repo_id;
1807
+ app.finetune_model_dir = resolved;
1808
+ app.current_project = Some(project);
1809
+ app.finetune_focus = FineTuneFocus::DataPath;
1810
+ app.finetune_running = false;
1811
+ app.finetune_progress = None;
1812
+ app.screen = Screen::FineTune;
1813
+ app.status = Status::neutral("Project ready. Configure fine-tuning.");
1814
+ }
1815
+ Err(e) => {
1816
+ app.status = Status::error(format!("Failed to create project: {e}"));
1817
+ }
1818
+ }
1819
+ return;
1820
+ }
1821
+
1822
+ // Not downloaded — trigger download via the existing HF search download flow
1823
+ let hub = crate::hf::preferred_download_hub();
1824
+ app.downloading = true;
1825
+ app.download_progress = None;
1826
+ app.status =
1827
+ Status::neutral(format!("Downloading base model {}…", base.display_name));
1828
+
1829
+ // Switch to Downloads screen to show progress
1830
+ app.screen = Screen::Downloads;
1831
+
1832
+ let (dl_tx, mut dl_rx) =
1833
+ mpsc::unbounded_channel::<crate::hf_search::DownloadEvent>();
1834
+
1835
+ let model_id_for_download = base_repo_id.clone();
1836
+ tokio::spawn(async move {
1837
+ crate::hf_search::download_model(model_id_for_download, hub, dl_tx).await;
1838
+ });
1839
+
1840
+ let auth_tx = tx.clone();
1841
+ let model_id_for_event = base_repo_id;
1842
+ tokio::spawn(async move {
1843
+ while let Some(event) = dl_rx.recv().await {
1844
+ match event {
1845
+ crate::hf_search::DownloadEvent::Progress(p) => {
1846
+ let _ = auth_tx.send(AuthEvent::ModelDownloadProgress(p));
1847
+ }
1848
+ crate::hf_search::DownloadEvent::Complete => {
1849
+ let _ = auth_tx.send(AuthEvent::ModelDownloadComplete(
1850
+ model_id_for_event.clone(),
1851
+ ));
1852
+ break;
1853
+ }
1854
+ crate::hf_search::DownloadEvent::Failed(e) => {
1855
+ let _ = auth_tx.send(AuthEvent::ModelDownloadFailed(e));
1856
+ break;
1857
+ }
1858
+ }
1859
+ }
1860
+ });
1861
+ }
1862
+ (Esc, _) => {
1863
+ // Go back to repo name input
1864
+ app.clone_repo_status = None;
1865
+ app.clone_base_cursor = 0;
1866
+ app.status = Status::neutral("Enter a HuggingFace repo ID.");
1867
+ }
1868
+ (Char('c'), KeyModifiers::CONTROL) => {
1869
+ app.should_quit = true;
1870
+ }
1871
+ _ => {}
1872
+ }
1873
+ return;
1874
+ }
1875
+
1876
+ // If repo has model files, show a note and allow going back
1877
+ if matches!(
1878
+ &app.clone_repo_status,
1879
+ Some(crate::hf_clone::RepoStatus::HasModel { .. })
1880
+ ) {
1881
+ match (key.code, key.modifiers) {
1882
+ (Esc, _) => {
1883
+ app.clone_repo_status = None;
1884
+ app.status = Status::neutral("Enter a HuggingFace repo ID.");
1885
+ }
1886
+ (Char('c'), KeyModifiers::CONTROL) => {
1887
+ app.should_quit = true;
1888
+ }
1889
+ _ => {}
1890
+ }
1891
+ return;
1892
+ }
1893
+
1894
+ // Repo status is NotFound — allow creating it with Enter
1895
+ if matches!(
1896
+ &app.clone_repo_status,
1897
+ Some(crate::hf_clone::RepoStatus::NotFound)
1898
+ ) {
1899
+ match (key.code, key.modifiers) {
1900
+ (Enter, _) => {
1901
+ let token = HF_TOKEN.to_string();
1902
+ if token.is_empty() {
1903
+ app.status = Status::error("No HF_TOKEN set. Add it to .env and rebuild.");
1904
+ return;
1905
+ }
1906
+ let repo_id = app.clone_repo_id.clone();
1907
+ app.clone_repo_checking = true;
1908
+ app.status = Status::neutral("Creating repo…");
1909
+
1910
+ let (clone_tx, mut clone_rx) = mpsc::unbounded_channel();
1911
+ let auth_tx = tx.clone();
1912
+ tokio::spawn(async move {
1913
+ crate::hf_clone::start_create_repo(repo_id, token, clone_tx).await;
1914
+ });
1915
+ tokio::spawn(async move {
1916
+ while let Some(progress) = clone_rx.recv().await {
1917
+ let _ = auth_tx.send(AuthEvent::CloneProgress(progress));
1918
+ }
1919
+ });
1920
+ }
1921
+ (Esc, _) => {
1922
+ app.clone_repo_status = None;
1923
+ app.status = Status::neutral("Enter a HuggingFace repo ID.");
1924
+ }
1925
+ (Char('c'), KeyModifiers::CONTROL) => {
1926
+ app.should_quit = true;
1927
+ }
1928
+ _ => {}
1929
+ }
1930
+ return;
1931
+ }
1932
+
1933
+ // Default: repo name input mode
1934
+ match (key.code, key.modifiers) {
1935
+ (Enter, _) => {
1936
+ if app.clone_repo_id.trim().is_empty() {
1937
+ app.status = Status::error("Repo ID cannot be empty.");
1938
+ return;
1939
+ }
1940
+ let token = HF_TOKEN.to_string();
1941
+ let repo_id = app.clone_repo_id.clone();
1942
+ app.clone_repo_checking = true;
1943
+ app.clone_repo_status = None;
1944
+ app.status = Status::neutral(format!("Checking {}…", repo_id));
1945
+
1946
+ let (clone_tx, mut clone_rx) = mpsc::unbounded_channel();
1947
+ let auth_tx = tx.clone();
1948
+ tokio::spawn(async move {
1949
+ crate::hf_clone::start_check_repo(repo_id, token, clone_tx).await;
1950
+ });
1951
+ tokio::spawn(async move {
1952
+ while let Some(progress) = clone_rx.recv().await {
1953
+ let _ = auth_tx.send(AuthEvent::CloneProgress(progress));
1954
+ }
1955
+ });
1956
+ }
1957
+ (Backspace, _) => {
1958
+ app.clone_repo_id.pop();
1959
+ }
1960
+ (Esc, _) => {
1961
+ app.screen = Screen::Downloads;
1962
+ app.clone_repo_id.clear();
1963
+ app.clone_repo_status = None;
1964
+ app.clone_base_cursor = 0;
1965
+ app.status = Status::neutral("Back to models.");
1966
+ }
1967
+ (Char('c'), KeyModifiers::CONTROL) => {
1968
+ app.should_quit = true;
1969
+ }
1970
+ (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
1971
+ // Typing clears any previous check result
1972
+ if app.clone_repo_status.is_some() {
1973
+ app.clone_repo_status = None;
1974
+ }
1975
+ app.clone_repo_id.push(c);
1976
+ }
1977
+ _ => {}
1978
+ }
1979
+}
1980
+
1981
+/// Generate a compact timestamp for unique artifact naming.
1982
+///
1983
+/// Uses Unix epoch seconds for simplicity — produces values like `1750012345`
1984
+/// which are unique, monotonic, and filesystem-safe.
1985
+fn run_timestamp() -> String {
1986
+ let secs = std::time::SystemTime::now()
1987
+ .duration_since(std::time::UNIX_EPOCH)
1988
+ .unwrap_or_default()
1989
+ .as_secs();
1990
+ format!("{secs}")
1991
+}
1992
+
1993
+/// Derive a per-project dataset path from a model ID.
1994
+///
1995
+/// Converts `org/model-name` → `~/.onde/datasets/org/model-name/train.jsonl`
1996
+/// so each fine-tuning project has its own dataset folder.
1997
+fn finetune_data_path_for_model(model_id: &str) -> String {
1998
+ format!("~/.onde/datasets/{}/train.jsonl", model_id)
1999
+}
2000
+
2001
fn trigger_hf_search(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2002
let query = app.hf_search_query.trim().to_string();
2003
if query.is_empty() {
src/finetune.rs
+63
-3
@@ -233,10 +233,12 @@ fn run_finetune(
233
lr: config.learning_rate,
234
..ParamsAdamW::default()
235
};
236
- let mut optimizer = AdamW::new(lora_vars, adamw_params).context("creating AdamW optimizer")?;
236
+ let mut optimizer =
237
+ AdamW::new(lora_vars.clone(), adamw_params).context("creating AdamW optimizer")?;
238
239
let total_steps = token_batches.len();
240
let vocab_size = model_cfg.vocab_size;
241
+ let max_grad_norm: f64 = 1.0;
242
243
for epoch in 0..config.epochs {
244
for (step, batch_tokens) in token_batches.iter().enumerate() {
@@ -259,10 +261,68 @@ fn run_finetune(
261
let loss = candle_nn::loss::cross_entropy(&logits_shifted, &targets)
262
.context("computing cross-entropy loss")?;
263
262
- optimizer.backward_step(&loss).context("optimizer step")?;
263
-
264
let loss_val = loss.to_scalar::<f32>().context("reading loss scalar")?;
265
266
+ // Guard against NaN/Inf loss — skip this step to avoid poisoning
267
+ // the optimizer state.
268
+ if loss_val.is_nan() || loss_val.is_infinite() {
269
+ eprintln!(
270
+ "[finetune] WARNING: loss is {} at epoch {} step {}, skipping",
271
+ loss_val,
272
+ epoch + 1,
273
+ step + 1
274
+ );
275
+ let _ = tx.send(FineTuneProgress::Training {
276
+ epoch: epoch + 1,
277
+ total_epochs: config.epochs,
278
+ step: step + 1,
279
+ total_steps,
280
+ loss: loss_val,
281
+ });
282
+ continue;
283
+ }
284
+
285
+ // Manual backward + gradient clipping + optimizer step.
286
+ //
287
+ // We avoid `backward_step` so we can clip gradients between the
288
+ // backward pass and the AdamW update. This preserves AdamW's
289
+ // momentum / velocity state (unlike manually applying lr * grad).
290
+ let grads = loss.backward().context("backward pass")?;
291
+
292
+ // Compute global gradient L2 norm across all LoRA variables.
293
+ let mut total_norm_sq: f64 = 0.0;
294
+ for var in &lora_vars {
295
+ if let Some(grad) = grads.get(var.as_tensor()) {
296
+ let norm_sq = grad
297
+ .sqr()
298
+ .and_then(|t| t.sum_all())
299
+ .and_then(|t| t.to_scalar::<f32>())
300
+ .unwrap_or(0.0) as f64;
301
+ total_norm_sq += norm_sq;
302
+ }
303
+ }
304
+ let total_norm = total_norm_sq.sqrt();
305
+
306
+ // If the global norm exceeds the threshold, scale every gradient
307
+ // down and insert the clipped version back into the GradStore.
308
+ let grads = if total_norm > max_grad_norm {
309
+ let clip_coef = max_grad_norm / (total_norm + 1e-6);
310
+ let mut clipped_grads = grads;
311
+ for var in &lora_vars {
312
+ if let Some(grad) = clipped_grads.get(var.as_tensor()) {
313
+ let clipped = (grad * clip_coef).context("clipping gradient")?;
314
+ clipped_grads.insert(var.as_tensor(), clipped);
315
+ }
316
+ }
317
+ clipped_grads
318
+ } else {
319
+ grads
320
+ };
321
+
322
+ // AdamW step with (possibly clipped) gradients — preserves
323
+ // first-moment and second-moment estimates.
324
+ optimizer.step(&grads).context("optimizer step")?;
325
+
326
let _ = tx.send(FineTuneProgress::Training {
327
epoch: epoch + 1,
328
total_epochs: config.epochs,
src/hf_clone.rs
new
+275
@@ -0,0 +1,275 @@
1
+//! HuggingFace repo inspection and base-model cloning for onde-cli.
2
+//!
3
+//! Provides helpers to:
4
+//! - Check whether a HuggingFace repo exists and if it contains model files
5
+//! - Fetch the list of recommended base models for fine-tuning
6
+//! - Clone (download) a base model's safetensors files into the local HF cache
7
+
8
+use anyhow::{Context, Result};
9
+use serde::Deserialize;
10
+
11
+// ---------------------------------------------------------------------------
12
+// Public types
13
+// ---------------------------------------------------------------------------
14
+
15
+/// Result of inspecting a HuggingFace repository.
16
+#[derive(Debug, Clone)]
17
+#[allow(dead_code)]
18
+pub enum RepoStatus {
19
+ /// Repo does not exist on HuggingFace (404).
20
+ NotFound,
21
+ /// Repo exists but contains no model files (safetensors/GGUF).
22
+ Empty { repo_id: String, files: Vec<String> },
23
+ /// Repo exists and has model files ready for use.
24
+ HasModel {
25
+ repo_id: String,
26
+ files: Vec<String>,
27
+ /// Approximate total size in bytes of all model files.
28
+ model_size_bytes: u64,
29
+ },
30
+}
31
+
32
+/// A base model that can be used as a starting point for fine-tuning.
33
+#[derive(Debug, Clone)]
34
+pub struct BaseModel {
35
+ /// HuggingFace repo ID, e.g. `Qwen/Qwen3-0.6B`
36
+ pub repo_id: &'static str,
37
+ /// Human-readable display name
38
+ pub display_name: &'static str,
39
+ /// Approximate size of the safetensors model files
40
+ pub size_display: &'static str,
41
+ /// Parameter count description
42
+ pub params: &'static str,
43
+ /// Short description of recommended use case
44
+ pub description: &'static str,
45
+}
46
+
47
+/// Progress updates emitted while checking or cloning a repo.
48
+#[derive(Debug, Clone)]
49
+pub enum CloneProgress {
50
+ /// Checking if the target repo exists on HuggingFace.
51
+ CheckingRepo,
52
+ /// Repo status has been determined.
53
+ RepoChecked(RepoStatus),
54
+ /// Creating the target repo on HuggingFace.
55
+ CreatingRepo,
56
+ /// Repo was created (or already existed).
57
+ RepoReady,
58
+ /// Failed with an error message.
59
+ Failed(String),
60
+}
61
+
62
+// ---------------------------------------------------------------------------
63
+// Base model catalog
64
+// ---------------------------------------------------------------------------
65
+
66
+/// Recommended base models for LoRA fine-tuning.
67
+///
68
+/// These are full-precision safetensors models (not GGUF) since fine-tuning
69
+/// requires autograd-capable weights. Ordered by size (smallest first).
70
+pub const BASE_MODELS: &[BaseModel] = &[
71
+ BaseModel {
72
+ repo_id: "Qwen/Qwen3-0.6B",
73
+ display_name: "Qwen3 0.6B",
74
+ size_display: "~1.2 GB",
75
+ params: "751M",
76
+ description: "Smallest Qwen3 — ideal for mobile & quick experiments",
77
+ },
78
+ BaseModel {
79
+ repo_id: "Qwen/Qwen2.5-1.5B-Instruct",
80
+ display_name: "Qwen2.5 1.5B Instruct",
81
+ size_display: "~3.0 GB",
82
+ params: "1.5B",
83
+ description: "Balanced size — good for instruction-following tasks",
84
+ },
85
+ BaseModel {
86
+ repo_id: "Qwen/Qwen3-1.7B",
87
+ display_name: "Qwen3 1.7B",
88
+ size_display: "~3.4 GB",
89
+ params: "1.7B",
90
+ description: "Latest Qwen3 small model — strong reasoning",
91
+ },
92
+ BaseModel {
93
+ repo_id: "Qwen/Qwen3-4B",
94
+ display_name: "Qwen3 4B",
95
+ size_display: "~8.0 GB",
96
+ params: "4B",
97
+ description: "Larger Qwen3 — best quality, macOS recommended",
98
+ },
99
+];
100
+
101
+// ---------------------------------------------------------------------------
102
+// HF API response types
103
+// ---------------------------------------------------------------------------
104
+
105
+#[derive(Debug, Deserialize)]
106
+struct HfRepoInfo {
107
+ #[serde(default)]
108
+ siblings: Vec<HfFileSibling>,
109
+}
110
+
111
+#[derive(Debug, Deserialize)]
112
+struct HfFileSibling {
113
+ rfilename: String,
114
+ #[serde(default)]
115
+ size: Option<u64>,
116
+}
117
+
118
+// ---------------------------------------------------------------------------
119
+// Public API
120
+// ---------------------------------------------------------------------------
121
+
122
+/// Check whether a HuggingFace repo exists and inspect its contents.
123
+///
124
+/// Returns [`RepoStatus::NotFound`] if the repo doesn't exist (HTTP 404),
125
+/// [`RepoStatus::Empty`] if it exists but has no model files, or
126
+/// [`RepoStatus::HasModel`] if it contains safetensors or GGUF files.
127
+pub async fn check_repo(repo_id: &str, hf_token: &str) -> Result<RepoStatus> {
128
+ let client = build_client()?;
129
+
130
+ let mut request = client.get(format!("https://huggingface.co/api/models/{repo_id}"));
131
+ if !hf_token.is_empty() {
132
+ request = request.bearer_auth(hf_token);
133
+ }
134
+
135
+ let response = request
136
+ .send()
137
+ .await
138
+ .context("Failed to reach HuggingFace API")?;
139
+
140
+ if response.status() == reqwest::StatusCode::NOT_FOUND {
141
+ return Ok(RepoStatus::NotFound);
142
+ }
143
+
144
+ let response = response
145
+ .error_for_status()
146
+ .context("HuggingFace API error")?;
147
+
148
+ let info: HfRepoInfo = response
149
+ .json()
150
+ .await
151
+ .context("Failed to parse repo metadata")?;
152
+
153
+ let all_files: Vec<String> = info.siblings.iter().map(|s| s.rfilename.clone()).collect();
154
+
155
+ // Check for model files: safetensors or GGUF
156
+ let model_files: Vec<&HfFileSibling> = info
157
+ .siblings
158
+ .iter()
159
+ .filter(|s| is_model_file(&s.rfilename))
160
+ .collect();
161
+
162
+ if model_files.is_empty() {
163
+ Ok(RepoStatus::Empty {
164
+ repo_id: repo_id.to_string(),
165
+ files: all_files,
166
+ })
167
+ } else {
168
+ let total_size: u64 = model_files.iter().filter_map(|s| s.size).sum();
169
+ Ok(RepoStatus::HasModel {
170
+ repo_id: repo_id.to_string(),
171
+ files: all_files,
172
+ model_size_bytes: total_size,
173
+ })
174
+ }
175
+}
176
+
177
+/// Create a HuggingFace repo if it doesn't already exist.
178
+///
179
+/// Returns `Ok(())` on success or if the repo already exists (HTTP 409).
180
+pub async fn create_repo(repo_id: &str, hf_token: &str) -> Result<()> {
181
+ let client = build_client()?;
182
+
183
+ let body = if let Some((org, name)) = repo_id.split_once('/') {
184
+ serde_json::json!({
185
+ "type": "model",
186
+ "name": name,
187
+ "organization": org,
188
+ "private": false,
189
+ "sdk": "onde-cli"
190
+ })
191
+ } else {
192
+ serde_json::json!({
193
+ "type": "model",
194
+ "name": repo_id,
195
+ "private": false,
196
+ "sdk": "onde-cli"
197
+ })
198
+ };
199
+
200
+ let response = client
201
+ .post("https://huggingface.co/api/repos/create")
202
+ .bearer_auth(hf_token)
203
+ .json(&body)
204
+ .send()
205
+ .await
206
+ .context("Failed to reach HuggingFace API")?;
207
+
208
+ let status = response.status();
209
+
210
+ // 200 = created, 409 = already exists — both are fine.
211
+ if !status.is_success() && status.as_u16() != 409 {
212
+ let body_text = response.text().await.unwrap_or_default();
213
+ anyhow::bail!("Failed to create repo {repo_id}: {status} {body_text}");
214
+ }
215
+
216
+ Ok(())
217
+}
218
+
219
+/// Spawn-friendly entry point that checks a repo and sends progress via channel.
220
+pub async fn start_check_repo(
221
+ repo_id: String,
222
+ hf_token: String,
223
+ tx: tokio::sync::mpsc::UnboundedSender<CloneProgress>,
224
+) {
225
+ let _ = tx.send(CloneProgress::CheckingRepo);
226
+
227
+ match check_repo(&repo_id, &hf_token).await {
228
+ Ok(status) => {
229
+ let _ = tx.send(CloneProgress::RepoChecked(status));
230
+ }
231
+ Err(e) => {
232
+ let _ = tx.send(CloneProgress::Failed(format!("{e:#}")));
233
+ }
234
+ }
235
+}
236
+
237
+/// Create the repo (if needed) and report progress.
238
+pub async fn start_create_repo(
239
+ repo_id: String,
240
+ hf_token: String,
241
+ tx: tokio::sync::mpsc::UnboundedSender<CloneProgress>,
242
+) {
243
+ let _ = tx.send(CloneProgress::CreatingRepo);
244
+
245
+ match create_repo(&repo_id, &hf_token).await {
246
+ Ok(()) => {
247
+ let _ = tx.send(CloneProgress::RepoReady);
248
+ }
249
+ Err(e) => {
250
+ let _ = tx.send(CloneProgress::Failed(format!("{e:#}")));
251
+ }
252
+ }
253
+}
254
+
255
+// ---------------------------------------------------------------------------
256
+// Private helpers
257
+// ---------------------------------------------------------------------------
258
+
259
+fn build_client() -> Result<reqwest::Client> {
260
+ reqwest::Client::builder()
261
+ .user_agent("onde-cli/0.2")
262
+ .redirect(reqwest::redirect::Policy::limited(10))
263
+ .build()
264
+ .context("Failed to build HTTP client")
265
+}
266
+
267
+/// Check if a filename looks like a model weight file.
268
+fn is_model_file(filename: &str) -> bool {
269
+ let lower = filename.to_lowercase();
270
+ lower.ends_with(".safetensors")
271
+ || lower.ends_with(".gguf")
272
+ || lower.ends_with(".bin")
273
+ || lower.ends_with(".pt")
274
+ || lower.ends_with(".pth")
275
+}
src/main.rs
+2
@@ -3,9 +3,11 @@ mod finetune;
3
mod gguf;
4
mod gresiq;
5
mod hf;
6
+mod hf_clone;
7
mod hf_search;
8
mod hf_upload;
9
mod merge;
10
+mod project;
11
mod token;
12
mod ui;
13
src/project.rs
new
+361
@@ -0,0 +1,361 @@
1
+#![allow(dead_code)]
2
+//! Project workspace management for onde-cli fine-tuning.
3
+//!
4
+//! Each target HuggingFace repo (e.g. `ondeinference/joko`) gets its own
5
+//! workspace under `~/.onde/projects/{org}/{name}/`. The base model lives
6
+//! in the shared HuggingFace cache (read-only); all generated artifacts
7
+//! (LoRA adapters, merged models, GGUF exports) go into the project dir.
8
+
9
+use std::path::{Path, PathBuf};
10
+
11
+use anyhow::{Context, Result};
12
+use serde::{Deserialize, Serialize};
13
+
14
+// ---------------------------------------------------------------------------
15
+// Public types
16
+// ---------------------------------------------------------------------------
17
+
18
+/// Metadata stored in `project.json` at the project root.
19
+#[derive(Debug, Clone, Serialize, Deserialize)]
20
+pub struct ProjectMeta {
21
+ /// Target HuggingFace repo ID, e.g. `ondeinference/joko`.
22
+ pub repo_id: String,
23
+ /// Base model HF repo used for fine-tuning, e.g. `Qwen/Qwen3-0.6B`.
24
+ pub base_model_id: String,
25
+ /// Unix timestamp when the project was created.
26
+ pub created_at: u64,
27
+}
28
+
29
+/// A resolved project workspace with all paths computed.
30
+#[derive(Debug, Clone)]
31
+pub struct OndeProject {
32
+ /// Target HF repo, e.g. `ondeinference/joko`.
33
+ pub repo_id: String,
34
+ /// Base model HF repo, e.g. `Qwen/Qwen3-0.6B`.
35
+ pub base_model_id: String,
36
+ /// Resolved path to the base model in the local HF cache.
37
+ pub base_model_dir: String,
38
+ /// Root of this project workspace, e.g. `~/.onde/projects/ondeinference/joko`.
39
+ pub project_dir: PathBuf,
40
+ /// Path to the dataset file: `{project_dir}/dataset/train.jsonl`.
41
+ pub dataset_path: PathBuf,
42
+ /// Path to the runs directory: `{project_dir}/runs/`.
43
+ pub runs_dir: PathBuf,
44
+}
45
+
46
+// ---------------------------------------------------------------------------
47
+// Public API
48
+// ---------------------------------------------------------------------------
49
+
50
+/// Root directory for all onde projects.
51
+pub fn projects_root() -> PathBuf {
52
+ dirs::home_dir()
53
+ .unwrap_or_else(|| PathBuf::from("."))
54
+ .join(".onde")
55
+ .join("projects")
56
+}
57
+
58
+/// Create a new project workspace on disk and return its metadata.
59
+///
60
+/// - Creates `~/.onde/projects/{repo_id}/` with `dataset/` and `runs/` subdirs.
61
+/// - Writes `project.json` with the repo ID and base model.
62
+/// - Does NOT overwrite an existing `project.json`.
63
+pub fn create_project(repo_id: &str, base_model_id: &str) -> Result<OndeProject> {
64
+ let project_dir = projects_root().join(repo_id);
65
+ let dataset_dir = project_dir.join("dataset");
66
+ let runs_dir = project_dir.join("runs");
67
+
68
+ std::fs::create_dir_all(&dataset_dir)
69
+ .with_context(|| format!("creating {}", dataset_dir.display()))?;
70
+ std::fs::create_dir_all(&runs_dir)
71
+ .with_context(|| format!("creating {}", runs_dir.display()))?;
72
+
73
+ let meta_path = project_dir.join("project.json");
74
+ let meta = if meta_path.exists() {
75
+ // Load existing — don't overwrite (user may have changed base_model_id)
76
+ let raw = std::fs::read_to_string(&meta_path)
77
+ .with_context(|| format!("reading {}", meta_path.display()))?;
78
+ serde_json::from_str::<ProjectMeta>(&raw)
79
+ .with_context(|| format!("parsing {}", meta_path.display()))?
80
+ } else {
81
+ let now = std::time::SystemTime::now()
82
+ .duration_since(std::time::UNIX_EPOCH)
83
+ .unwrap_or_default()
84
+ .as_secs();
85
+ let meta = ProjectMeta {
86
+ repo_id: repo_id.to_string(),
87
+ base_model_id: base_model_id.to_string(),
88
+ created_at: now,
89
+ };
90
+ let json = serde_json::to_string_pretty(&meta).context("serializing project.json")?;
91
+ std::fs::write(&meta_path, json)
92
+ .with_context(|| format!("writing {}", meta_path.display()))?;
93
+ meta
94
+ };
95
+
96
+ Ok(OndeProject {
97
+ repo_id: meta.repo_id,
98
+ base_model_id: meta.base_model_id.clone(),
99
+ base_model_dir: String::new(), // resolved later by the caller
100
+ project_dir,
101
+ dataset_path: dataset_dir.join("train.jsonl"),
102
+ runs_dir,
103
+ })
104
+}
105
+
106
+/// Load an existing project from disk.
107
+pub fn load_project(repo_id: &str) -> Result<OndeProject> {
108
+ let project_dir = projects_root().join(repo_id);
109
+ let meta_path = project_dir.join("project.json");
110
+
111
+ if !meta_path.exists() {
112
+ anyhow::bail!("no project found at {}", project_dir.display());
113
+ }
114
+
115
+ let raw = std::fs::read_to_string(&meta_path)
116
+ .with_context(|| format!("reading {}", meta_path.display()))?;
117
+ let meta: ProjectMeta =
118
+ serde_json::from_str(&raw).with_context(|| format!("parsing {}", meta_path.display()))?;
119
+
120
+ let dataset_path = project_dir.join("dataset").join("train.jsonl");
121
+ let runs_dir = project_dir.join("runs");
122
+
123
+ Ok(OndeProject {
124
+ repo_id: meta.repo_id,
125
+ base_model_id: meta.base_model_id,
126
+ base_model_dir: String::new(),
127
+ project_dir,
128
+ dataset_path,
129
+ runs_dir,
130
+ })
131
+}
132
+
133
+/// List all existing projects under `~/.onde/projects/`.
134
+///
135
+/// Returns `(repo_id, ProjectMeta)` pairs sorted alphabetically.
136
+pub fn list_projects() -> Vec<(String, ProjectMeta)> {
137
+ let root = projects_root();
138
+ let mut projects = Vec::new();
139
+
140
+ let Ok(orgs) = std::fs::read_dir(&root) else {
141
+ return projects;
142
+ };
143
+
144
+ for org_entry in orgs.flatten() {
145
+ let org_path = org_entry.path();
146
+ if !org_path.is_dir() {
147
+ continue;
148
+ }
149
+ let org_name = org_entry.file_name().to_string_lossy().to_string();
150
+
151
+ let Ok(repos) = std::fs::read_dir(&org_path) else {
152
+ continue;
153
+ };
154
+
155
+ for repo_entry in repos.flatten() {
156
+ let repo_path = repo_entry.path();
157
+ let meta_path = repo_path.join("project.json");
158
+ if !meta_path.exists() {
159
+ continue;
160
+ }
161
+
162
+ let repo_name = repo_entry.file_name().to_string_lossy().to_string();
163
+ let repo_id = format!("{org_name}/{repo_name}");
164
+
165
+ if let Ok(raw) = std::fs::read_to_string(&meta_path) {
166
+ if let Ok(meta) = serde_json::from_str::<ProjectMeta>(&raw) {
167
+ projects.push((repo_id, meta));
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ projects.sort_by(|a, b| a.0.cmp(&b.0));
174
+ projects
175
+}
176
+
177
+/// Generate a new timestamped run directory under the project.
178
+///
179
+/// Returns the path to the run dir (e.g. `{project}/runs/1750012345/`).
180
+/// The directory is created on disk.
181
+pub fn new_run_dir(project: &OndeProject) -> Result<PathBuf> {
182
+ let ts = std::time::SystemTime::now()
183
+ .duration_since(std::time::UNIX_EPOCH)
184
+ .unwrap_or_default()
185
+ .as_secs();
186
+ let run_dir = project.runs_dir.join(format!("{ts}"));
187
+ std::fs::create_dir_all(&run_dir)
188
+ .with_context(|| format!("creating run dir {}", run_dir.display()))?;
189
+ Ok(run_dir)
190
+}
191
+
192
+/// Scan a project's runs directory for all artifacts (LoRA adapters and GGUFs).
193
+///
194
+/// Each run is a timestamped subdirectory under `{project}/runs/`.
195
+/// Returns entries sorted by modification time (newest first).
196
+pub fn scan_project_artifacts(project: &OndeProject) -> Vec<ProjectArtifact> {
197
+ let mut artifacts = Vec::new();
198
+
199
+ let Ok(runs) = std::fs::read_dir(&project.runs_dir) else {
200
+ return artifacts;
201
+ };
202
+
203
+ for run_entry in runs.flatten() {
204
+ let run_dir = run_entry.path();
205
+ if !run_dir.is_dir() {
206
+ continue;
207
+ }
208
+ let run_name = run_entry.file_name().to_string_lossy().to_string();
209
+
210
+ // Check for LoRA adapter
211
+ let lora_file = run_dir.join("lora_adapter.safetensors");
212
+ if lora_file.is_file() {
213
+ if let Some(a) = artifact_from_path(&lora_file, &run_name, ArtifactType::LoraAdapter) {
214
+ artifacts.push(a);
215
+ }
216
+ }
217
+
218
+ // Check for merged model
219
+ let merged_dir = run_dir.join("merged");
220
+ if merged_dir.is_dir() {
221
+ let merged_safetensors = merged_dir.join("model.safetensors");
222
+ if merged_safetensors.is_file() {
223
+ if let Some(a) =
224
+ artifact_from_path(&merged_safetensors, &run_name, ArtifactType::MergedModel)
225
+ {
226
+ artifacts.push(a);
227
+ }
228
+ }
229
+ }
230
+
231
+ // Check for GGUF files
232
+ if let Ok(files) = std::fs::read_dir(&run_dir) {
233
+ for file in files.flatten() {
234
+ let fname = file.file_name().to_string_lossy().to_string();
235
+ if fname.ends_with(".gguf") {
236
+ if let Some(a) = artifact_from_path(&file.path(), &run_name, ArtifactType::Gguf)
237
+ {
238
+ artifacts.push(a);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ // Newest first
246
+ artifacts.sort_by(|a, b| b.modified_ts.cmp(&a.modified_ts));
247
+ artifacts
248
+}
249
+
250
+// ---------------------------------------------------------------------------
251
+// Artifact types
252
+// ---------------------------------------------------------------------------
253
+
254
+#[derive(Debug, Clone, PartialEq)]
255
+pub enum ArtifactType {
256
+ LoraAdapter,
257
+ MergedModel,
258
+ Gguf,
259
+}
260
+
261
+/// An artifact found in a project's runs directory.
262
+#[derive(Debug, Clone)]
263
+pub struct ProjectArtifact {
264
+ /// Full path to the artifact file.
265
+ pub path: PathBuf,
266
+ /// Name of the run directory (timestamp).
267
+ pub run_name: String,
268
+ /// Filename.
269
+ pub file_name: String,
270
+ /// Human-readable file size.
271
+ pub size_display: String,
272
+ /// Relative time display ("just now", "3h ago").
273
+ pub modified_display: String,
274
+ /// Raw modification timestamp for sorting.
275
+ pub modified_ts: u64,
276
+ /// What kind of artifact this is.
277
+ pub kind: ArtifactType,
278
+}
279
+
280
+// ---------------------------------------------------------------------------
281
+// Private helpers
282
+// ---------------------------------------------------------------------------
283
+
284
+fn artifact_from_path(path: &Path, run_name: &str, kind: ArtifactType) -> Option<ProjectArtifact> {
285
+ let meta = std::fs::metadata(path).ok()?;
286
+ let size = if meta.is_dir() {
287
+ dir_size(path)
288
+ } else {
289
+ meta.len()
290
+ };
291
+ let modified_ts = meta
292
+ .modified()
293
+ .ok()
294
+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
295
+ .map(|d| d.as_secs())
296
+ .unwrap_or(0);
297
+
298
+ let now = std::time::SystemTime::now()
299
+ .duration_since(std::time::UNIX_EPOCH)
300
+ .unwrap_or_default()
301
+ .as_secs();
302
+ let age_secs = now.saturating_sub(modified_ts);
303
+ let modified_display = format_age(age_secs);
304
+
305
+ Some(ProjectArtifact {
306
+ path: path.to_path_buf(),
307
+ run_name: run_name.to_string(),
308
+ file_name: path
309
+ .file_name()
310
+ .unwrap_or_default()
311
+ .to_string_lossy()
312
+ .to_string(),
313
+ size_display: format_size(size),
314
+ modified_display,
315
+ modified_ts,
316
+ kind,
317
+ })
318
+}
319
+
320
+fn dir_size(path: &Path) -> u64 {
321
+ let mut total = 0u64;
322
+ if let Ok(entries) = std::fs::read_dir(path) {
323
+ for entry in entries.flatten() {
324
+ let p = entry.path();
325
+ if p.is_file() {
326
+ total += p.metadata().map(|m| m.len()).unwrap_or(0);
327
+ } else if p.is_dir() {
328
+ total += dir_size(&p);
329
+ }
330
+ }
331
+ }
332
+ total
333
+}
334
+
335
+fn format_size(bytes: u64) -> String {
336
+ const KB: u64 = 1_024;
337
+ const MB: u64 = 1_024 * KB;
338
+ const GB: u64 = 1_024 * MB;
339
+
340
+ if bytes >= GB {
341
+ format!("{:.1}GB", bytes as f64 / GB as f64)
342
+ } else if bytes >= MB {
343
+ format!("{}MB", bytes / MB)
344
+ } else if bytes >= KB {
345
+ format!("{}KB", bytes / KB)
346
+ } else {
347
+ format!("{}B", bytes)
348
+ }
349
+}
350
+
351
+fn format_age(secs: u64) -> String {
352
+ if secs < 60 {
353
+ "just now".to_string()
354
+ } else if secs < 3600 {
355
+ format!("{}m ago", secs / 60)
356
+ } else if secs < 86400 {
357
+ format!("{}h ago", secs / 3600)
358
+ } else {
359
+ format!("{}d ago", secs / 86400)
360
+ }
361
+}
src/ui.rs
+174
@@ -86,6 +86,7 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) {
86
Screen::ModelDetail => render_model_detail(frame, app, inner),
87
Screen::GgufDetail => render_gguf_detail(frame, app, inner),
88
Screen::FineTune => render_finetune(frame, app, inner),
89
+ Screen::CloneRepo => render_clone_repo(frame, app, inner),
90
}
91
}
92
@@ -1976,6 +1977,148 @@ fn render_adapter_list(frame: &mut Frame, app: &App, area: Rect) {
1977
}
1978
}
1979
1980
+fn render_clone_repo(frame: &mut Frame, app: &App, area: Rect) {
1981
+ let is_picking_base = matches!(
1982
+ &app.clone_repo_status,
1983
+ Some(crate::hf_clone::RepoStatus::Empty { .. })
1984
+ );
1985
+
1986
+ let rows = Layout::vertical([
1987
+ Constraint::Length(1), // 0 heading
1988
+ Constraint::Length(1), // 1 spacer
1989
+ Constraint::Length(1), // 2 status
1990
+ Constraint::Length(1), // 3 spacer
1991
+ Constraint::Length(1), // 4 Repo ID label
1992
+ Constraint::Length(3), // 5 Repo ID input
1993
+ Constraint::Length(1), // 6 spacer
1994
+ Constraint::Length(1), // 7 result heading
1995
+ Constraint::Min(0), // 8 result / base model list
1996
+ ])
1997
+ .split(area);
1998
+
1999
+ // Heading
2000
+ frame.render_widget(
2001
+ Paragraph::new(Line::from(Span::styled(
2002
+ "Clone / Check HuggingFace Repo",
2003
+ Style::new().fg(C_INK).bold(),
2004
+ ))),
2005
+ rows[0],
2006
+ );
2007
+
2008
+ render_status(frame, app, rows[2]);
2009
+
2010
+ // Repo ID input
2011
+ frame.render_widget(
2012
+ Paragraph::new("Repo ID (e.g. ondeinference/joko)").style(Style::new().fg(C_MUTED)),
2013
+ rows[4],
2014
+ );
2015
+
2016
+ let input_focused = !app.clone_repo_checking && !is_picking_base;
2017
+ let border_style = if input_focused {
2018
+ Style::new().fg(C_NEON)
2019
+ } else {
2020
+ Style::new().fg(C_LINE)
2021
+ };
2022
+ let input_block = Block::new()
2023
+ .borders(Borders::ALL)
2024
+ .border_type(BorderType::Rounded)
2025
+ .border_style(border_style)
2026
+ .style(Style::new().bg(C_SURFACE_STRONG));
2027
+ let input_inner = input_block.inner(rows[5]);
2028
+ frame.render_widget(input_block, rows[5]);
2029
+ frame.render_widget(
2030
+ Paragraph::new(app.clone_repo_id.as_str()).style(Style::new().fg(C_TEXT)),
2031
+ input_inner,
2032
+ );
2033
+ if input_focused {
2034
+ let cursor_x = (input_inner.x + app.clone_repo_id.chars().count() as u16)
2035
+ .min(input_inner.x + input_inner.width.saturating_sub(1));
2036
+ frame.set_cursor_position((cursor_x, input_inner.y));
2037
+ }
2038
+
2039
+ // Result section
2040
+ match &app.clone_repo_status {
2041
+ Some(crate::hf_clone::RepoStatus::NotFound) => {
2042
+ frame.render_widget(
2043
+ Paragraph::new(Line::from(vec![
2044
+ Span::styled("✗ ", Style::new().fg(C_DANGER)),
2045
+ Span::styled(
2046
+ "Repo not found. Press Enter to create it.",
2047
+ Style::new().fg(C_MUTED),
2048
+ ),
2049
+ ])),
2050
+ rows[7],
2051
+ );
2052
+ }
2053
+ Some(crate::hf_clone::RepoStatus::HasModel { files, .. }) => {
2054
+ frame.render_widget(
2055
+ Paragraph::new(Line::from(vec![
2056
+ Span::styled("✓ ", Style::new().fg(C_NEON)),
2057
+ Span::styled(
2058
+ format!("Repo has {} model file(s). Already set up.", files.len()),
2059
+ Style::new().fg(C_NEON),
2060
+ ),
2061
+ ])),
2062
+ rows[7],
2063
+ );
2064
+ }
2065
+ Some(crate::hf_clone::RepoStatus::Empty { .. }) => {
2066
+ frame.render_widget(
2067
+ Paragraph::new("Select a base model for fine-tuning:")
2068
+ .style(Style::new().fg(C_MUTED)),
2069
+ rows[7],
2070
+ );
2071
+
2072
+ // Render base model list
2073
+ let base_models = crate::hf_clone::BASE_MODELS;
2074
+ let items: Vec<Line> = base_models
2075
+ .iter()
2076
+ .enumerate()
2077
+ .map(|(i, m)| {
2078
+ let selected = i == app.clone_base_cursor;
2079
+ let prefix = if selected { "▸ " } else { " " };
2080
+ let style = if selected {
2081
+ Style::new().fg(C_NEON).bold()
2082
+ } else {
2083
+ Style::new().fg(C_TEXT)
2084
+ };
2085
+ Line::from(vec![
2086
+ Span::styled(prefix, style),
2087
+ Span::styled(m.display_name, style),
2088
+ Span::styled(
2089
+ format!(" {} {}", m.size_display, m.params),
2090
+ Style::new().fg(C_MUTED),
2091
+ ),
2092
+ ])
2093
+ })
2094
+ .collect();
2095
+
2096
+ // Add description of selected model below the list
2097
+ let selected_desc = base_models
2098
+ .get(app.clone_base_cursor)
2099
+ .map(|m| m.description)
2100
+ .unwrap_or("");
2101
+
2102
+ let mut all_lines = items;
2103
+ all_lines.push(Line::from(""));
2104
+ all_lines.push(Line::from(Span::styled(
2105
+ selected_desc,
2106
+ Style::new().fg(C_MUTED).italic(),
2107
+ )));
2108
+
2109
+ frame.render_widget(Paragraph::new(all_lines), rows[8]);
2110
+ }
2111
+ None => {
2112
+ if app.clone_repo_checking {
2113
+ frame.render_widget(
2114
+ Paragraph::new("⠿ Checking…").style(Style::new().fg(C_MUTED)),
2115
+ rows[7],
2116
+ );
2117
+ }
2118
+ }
2119
+ }
2120
+}
2121
+
2122
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2123
let keys: Vec<Span> = match app.screen {
2124
Screen::Auth => vec![
@@ -2059,6 +2202,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2202
Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2203
Span::styled("/", Style::new().fg(C_NEON)),
2204
Span::styled(" · search HF ", Style::new().fg(C_MUTED)),
2205
+ Span::styled("c", Style::new().fg(C_NEON)),
2206
+ Span::styled(" · clone repo ", Style::new().fg(C_MUTED)),
2207
Span::styled("Enter", Style::new().fg(C_NEON)),
2208
Span::styled(" · detail ", Style::new().fg(C_MUTED)),
2209
Span::styled("Tab", Style::new().fg(C_NEON)),
@@ -2129,6 +2274,35 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2274
Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2275
Span::styled(" · quit", Style::new().fg(C_MUTED)),
2276
],
2277
+ Screen::CloneRepo if app.clone_repo_checking => vec![
2278
+ Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2279
+ Span::styled(" · quit", Style::new().fg(C_MUTED)),
2280
+ ],
2281
+ Screen::CloneRepo
2282
+ if matches!(
2283
+ &app.clone_repo_status,
2284
+ Some(crate::hf_clone::RepoStatus::Empty { .. })
2285
+ ) =>
2286
+ {
2287
+ vec![
2288
+ Span::styled("↑↓", Style::new().fg(C_NEON)),
2289
+ Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2290
+ Span::styled("Enter", Style::new().fg(C_NEON)),
2291
+ Span::styled(" · select & download ", Style::new().fg(C_MUTED)),
2292
+ Span::styled("Esc", Style::new().fg(C_NEON)),
2293
+ Span::styled(" · back ", Style::new().fg(C_MUTED)),
2294
+ Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2295
+ Span::styled(" · quit", Style::new().fg(C_MUTED)),
2296
+ ]
2297
+ }
2298
+ Screen::CloneRepo => vec![
2299
+ Span::styled("Enter", Style::new().fg(C_NEON)),
2300
+ Span::styled(" · check repo ", Style::new().fg(C_MUTED)),
2301
+ Span::styled("Esc", Style::new().fg(C_NEON)),
2302
+ Span::styled(" · back ", Style::new().fg(C_MUTED)),
2303
+ Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2304
+ Span::styled(" · quit", Style::new().fg(C_MUTED)),
2305
+ ],
2306
};
2307
2308
frame.render_widget(