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
]);