@ondeinference / cli / commits / e4463de

Support GGUF model exports alongside LoRA adapters

- Add ArtifactKind enum to distinguish artifact types - Update AdapterEntry to include file_name and kind - scan_adapters now finds both LoRA adapters and GGUF files - Update UI to display both artifact types in the model detail screen

keypair34 committed Apr 22, 2026 at 21:41 UTC e4463deeca5a9c46a4f3f00474d30f7ed7120e31
2 files changed +133 -53
src/app.rs
+106 -41
@@ -44,17 +44,30 @@ pub enum Screen {
44 FineTune,
45 }
46
47 -/// A discovered LoRA adapter on disk.
47 +/// What kind of artifact was found on disk.
48 +#[derive(Clone, PartialEq)]
49 +pub enum ArtifactKind {
50 + /// A LoRA adapter (lora_adapter.safetensors).
51 + LoraAdapter,
52 + /// An exported GGUF model (.gguf file).
53 + Gguf,
54 +}
55 +
56 +/// A discovered artifact (adapter or exported model) on disk.
57 #[derive(Clone)]
58 pub struct AdapterEntry {
50 - /// Full path to the `lora_adapter.safetensors` file.
59 + /// Full path to the file.
60 pub path: std::path::PathBuf,
61 /// Directory name (snapshot hash or "lora-adapter").
62 pub dir_name: String,
63 + /// File name for GGUF files, or "lora_adapter.safetensors" for adapters.
64 + pub file_name: String,
65 /// Human-readable file size.
66 pub size: String,
67 /// Relative timestamp ("just now", "3h ago", etc.).
68 pub modified: String,
69 + /// Whether this is a LoRA adapter or a GGUF export.
70 + pub kind: ArtifactKind,
71 }
72
73 #[derive(Debug, Clone, PartialEq)]
@@ -1028,8 +1041,13 @@ fn handle_key_model_detail(
1041 app.adapter_cursor += 1;
1042 }
1043 }
1031 - // Enter or 'm' — merge the selected adapter (skip fine-tuning)
1032 - (Enter, _) | (Char('m'), KeyModifiers::NONE) if !app.adapter_list.is_empty() => {
1044 + // Enter or 'm' — merge the selected LoRA adapter (skip fine-tuning)
1045 + (Enter, _) | (Char('m'), KeyModifiers::NONE)
1046 + if app
1047 + .adapter_list
1048 + .get(app.adapter_cursor)
1049 + .is_some_and(|a| a.kind == ArtifactKind::LoraAdapter) =>
1050 + {
1051 if let Some(adapter) = app.adapter_list.get(app.adapter_cursor) {
1052 if let Some(model) = app.downloads.get(app.downloads_cursor) {
1053 let resolved = resolve_hf_cache_path(&model.model_id);
@@ -1136,8 +1154,9 @@ fn resolve_hf_cache_path(model_id: &str) -> String {
1154 String::new()
1155 }
1156
1139 -/// Scan for existing `lora_adapter.safetensors` files under the model's snapshots
1140 -/// directory. Returns entries sorted by modification time (newest first).
1157 +/// Scan for existing `lora_adapter.safetensors` files and `.gguf` exports under
1158 +/// the model's snapshots directory. Returns entries sorted by modification time
1159 +/// (newest first).
1160 fn scan_adapters(model_dir: &str) -> Vec<AdapterEntry> {
1161 if model_dir.is_empty() {
1162 return Vec::new();
@@ -1146,10 +1165,10 @@ fn scan_adapters(model_dir: &str) -> Vec<AdapterEntry> {
1165 .parent()
1166 .unwrap_or_else(|| std::path::Path::new(""));
1167
1149 - let mut adapters: Vec<AdapterEntry> = Vec::new();
1168 + let mut artifacts: Vec<AdapterEntry> = Vec::new();
1169
1170 let Ok(entries) = std::fs::read_dir(snapshots_dir) else {
1152 - return adapters;
1171 + return artifacts;
1172 };
1173
1174 for entry in entries.flatten() {
@@ -1157,43 +1176,54 @@ fn scan_adapters(model_dir: &str) -> Vec<AdapterEntry> {
1176 if !dir.is_dir() {
1177 continue;
1178 }
1179 + let dir_name = entry.file_name().to_string_lossy().to_string();
1180 +
1181 + // Check for LoRA adapter
1182 let adapter_file = dir.join("lora_adapter.safetensors");
1161 - if !adapter_file.exists() {
1162 - continue;
1183 + if adapter_file.exists()
1184 + && let Some(ae) = make_artifact_entry(
1185 + &adapter_file,
1186 + &dir_name,
1187 + "lora_adapter.safetensors",
1188 + ArtifactKind::LoraAdapter,
1189 + )
1190 + {
1191 + artifacts.push(ae);
1192 }
1164 - let dir_name = entry.file_name().to_string_lossy().to_string();
1165 - let meta = std::fs::metadata(&adapter_file);
1166 - let size = meta
1167 - .as_ref()
1168 - .map(|m| format_adapter_size(m.len()))
1169 - .unwrap_or_else(|_| "–".to_string());
1170 - let modified = meta
1171 - .ok()
1172 - .and_then(|m| m.modified().ok())
1173 - .map(|t| {
1174 - let elapsed = t.elapsed().unwrap_or_default();
1175 - let secs = elapsed.as_secs();
1176 - if secs < 60 {
1177 - "just now".to_string()
1178 - } else if secs < 3600 {
1179 - format!("{}m ago", secs / 60)
1180 - } else if secs < 86400 {
1181 - format!("{}h ago", secs / 3600)
1182 - } else {
1183 - format!("{}d ago", secs / 86400)
1193 +
1194 + // Check for any .gguf files in this subdirectory
1195 + if let Ok(sub_entries) = std::fs::read_dir(&dir) {
1196 + for sub in sub_entries.flatten() {
1197 + let fname = sub.file_name().to_string_lossy().to_string();
1198 + if fname.ends_with(".gguf")
1199 + && let Some(ae) =
1200 + make_artifact_entry(&sub.path(), &dir_name, &fname, ArtifactKind::Gguf)
1201 + {
1202 + artifacts.push(ae);
1203 }
1185 - })
1186 - .unwrap_or_else(|| "–".to_string());
1187 - adapters.push(AdapterEntry {
1188 - path: adapter_file,
1189 - dir_name,
1190 - size,
1191 - modified,
1192 - });
1204 + }
1205 + }
1206 }
1207
1195 - // Sort newest first (largest mtime string first is unreliable, use the file)
1196 - adapters.sort_by(|a, b| {
1208 + // Also check the snapshots parent directory itself for .gguf files
1209 + // (GGUF export writes to e.g. .../snapshots/model-finetuned-q8_0.gguf)
1210 + if let Ok(parent_entries) = std::fs::read_dir(snapshots_dir) {
1211 + for entry in parent_entries.flatten() {
1212 + let path = entry.path();
1213 + if path.is_file() {
1214 + let fname = entry.file_name().to_string_lossy().to_string();
1215 + if fname.ends_with(".gguf")
1216 + && let Some(ae) =
1217 + make_artifact_entry(&path, "snapshots", &fname, ArtifactKind::Gguf)
1218 + {
1219 + artifacts.push(ae);
1220 + }
1221 + }
1222 + }
1223 + }
1224 +
1225 + // Sort newest first
1226 + artifacts.sort_by(|a, b| {
1227 let ma = std::fs::metadata(&a.path)
1228 .ok()
1229 .and_then(|m| m.modified().ok());
@@ -1203,7 +1233,42 @@ fn scan_adapters(model_dir: &str) -> Vec<AdapterEntry> {
1233 mb.cmp(&ma)
1234 });
1235
1206 - adapters
1236 + artifacts
1237 +}
1238 +
1239 +fn make_artifact_entry(
1240 + path: &std::path::Path,
1241 + dir_name: &str,
1242 + file_name: &str,
1243 + kind: ArtifactKind,
1244 +) -> Option<AdapterEntry> {
1245 + let meta = std::fs::metadata(path).ok()?;
1246 + let size = format_adapter_size(meta.len());
1247 + let modified = meta
1248 + .modified()
1249 + .ok()
1250 + .map(|t| {
1251 + let elapsed = t.elapsed().unwrap_or_default();
1252 + let secs = elapsed.as_secs();
1253 + if secs < 60 {
1254 + "just now".to_string()
1255 + } else if secs < 3600 {
1256 + format!("{}m ago", secs / 60)
1257 + } else if secs < 86400 {
1258 + format!("{}h ago", secs / 3600)
1259 + } else {
1260 + format!("{}d ago", secs / 86400)
1261 + }
1262 + })
1263 + .unwrap_or_else(|| "–".to_string());
1264 + Some(AdapterEntry {
1265 + path: path.to_path_buf(),
1266 + dir_name: dir_name.to_string(),
1267 + file_name: file_name.to_string(),
1268 + size,
1269 + modified,
1270 + kind,
1271 + })
1272 }
1273
1274 fn format_adapter_size(bytes: u64) -> String {
src/ui.rs
+27 -12
@@ -1,5 +1,5 @@
1 use {
2 - crate::app::{App, FineTuneFocus, Focus, Mode, OndeApp, Screen, StatusTone},
2 + crate::app::{App, ArtifactKind, FineTuneFocus, Focus, Mode, OndeApp, Screen, StatusTone},
3 crate::hf::CacheSource,
4 ratatui::{
5 Frame,
@@ -1087,11 +1087,11 @@ fn render_model_detail(frame: &mut Frame, app: &App, area: Rect) {
1087 rows[26],
1088 );
1089
1090 - // Adapters section
1090 + // Artifacts section (adapters + exported GGUFs)
1091 let adapter_heading = if app.adapter_list.is_empty() {
1092 - "LoRA Adapters"
1092 + "Artifacts"
1093 } else {
1094 - "LoRA Adapters (↑↓ select · m merge & export)"
1094 + "Artifacts (↑↓ select · Enter merge & export)"
1095 };
1096 frame.render_widget(
1097 Paragraph::new(adapter_heading).style(Style::new().fg(C_MUTED)),
@@ -1100,11 +1100,11 @@ fn render_model_detail(frame: &mut Frame, app: &App, area: Rect) {
1100 render_model_detail_adapters(frame, app, rows[29]);
1101 }
1102
1103 -/// Render the selectable adapter list inside the Model Detail screen.
1103 +/// Render the selectable artifact list inside the Model Detail screen.
1104 fn render_model_detail_adapters(frame: &mut Frame, app: &App, area: Rect) {
1105 if app.adapter_list.is_empty() {
1106 frame.render_widget(
1107 - Paragraph::new(" No adapters found. Press f to fine-tune.")
1107 + Paragraph::new(" No artifacts found. Press f to fine-tune.")
1108 .style(Style::new().fg(C_MUTED)),
1109 area,
1110 );
@@ -1130,17 +1130,32 @@ fn render_model_detail_adapters(frame: &mut Frame, app: &App, area: Rect) {
1130 };
1131 let meta_style = Style::new().fg(C_MUTED);
1132
1133 - // Truncate dir_name for display
1134 - let dir_display = if adapter.dir_name.len() > 16 {
1135 - format!("{}…", &adapter.dir_name[..15])
1133 + // Kind badge + icon
1134 + let (kind_icon, kind_label) = match adapter.kind {
1135 + ArtifactKind::LoraAdapter => ("◆ ", "LoRA"),
1136 + ArtifactKind::Gguf => ("● ", "GGUF"),
1137 + };
1138 + let kind_color = match adapter.kind {
1139 + ArtifactKind::LoraAdapter => C_NEON,
1140 + ArtifactKind::Gguf => Color::Rgb(100, 200, 255),
1141 + };
1142 +
1143 + // Show file_name for GGUF, dir_name for LoRA
1144 + let display_name = match adapter.kind {
1145 + ArtifactKind::Gguf => &adapter.file_name,
1146 + ArtifactKind::LoraAdapter => &adapter.dir_name,
1147 + };
1148 + let name_truncated = if display_name.len() > 28 {
1149 + format!("{}…", &display_name[..27])
1150 } else {
1137 - adapter.dir_name.clone()
1151 + display_name.clone()
1152 };
1153
1154 let line = Line::from(vec![
1155 Span::styled(marker, marker_style),
1142 - Span::styled("◆ ", Style::new().fg(C_NEON)),
1143 - Span::styled(format!("{:<18}", dir_display), name_style),
1156 + Span::styled(kind_icon, Style::new().fg(kind_color)),
1157 + Span::styled(format!("{:<6}", kind_label), Style::new().fg(kind_color)),
1158 + Span::styled(format!("{:<30}", name_truncated), name_style),
1159 Span::styled(format!("{:<10}", adapter.size), meta_style),
1160 Span::styled(&adapter.modified, meta_style),
1161 ]);