llm_manager/tui/app/
sync_ops.rs1use super::types::App;
2use std::path::PathBuf;
3
4impl App {
5 pub fn render<T: ratatui::backend::Backend>(
6 &mut self,
7 terminal: &mut ratatui::Terminal<T>,
8 ) -> std::io::Result<()> {
9 if self.ui.needs_full_redraw {
10 terminal.clear()?;
11 self.ui.needs_full_redraw = false;
12 }
13 terminal.draw(|frame| crate::tui::render::render(frame, self))?;
14 Ok(())
15 }
16
17 pub fn discover_models(dirs: &[PathBuf]) -> Vec<crate::models::DiscoveredModel> {
18 let mut models = Vec::new();
19 for dir in dirs {
20 crate::backend::hub::walk_dir_recursive(dir, 0, 10, &mut |entry| {
21 let path = entry.path();
22 if path.is_file() && path.extension().map(|e| e == "gguf").unwrap_or(false)
23 && let Some(name) = path.file_name().and_then(|n| n.to_str()) {
24 let name = name.to_string();
25 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
26 let display_name = path
27 .strip_prefix(dir)
28 .ok()
29 .and_then(|p| p.to_str())
30 .unwrap_or(&name)
31 .to_string();
32 models.push(crate::models::DiscoveredModel {
33 path,
34 name,
35 file_size: size,
36 display_name,
37 });
38 }
39 });
40 }
41 models.sort_by(|a, b| a.name.cmp(&b.name));
42 models
43 }
44
45 pub fn reset_to_defaults(&mut self) {
46 let defaults = crate::models::ModelSettings::default();
47 self.settings = defaults;
48 self.model_settings_cache = self.settings.clone();
50 self.loading.model_total_layers = 0;
52 self.loading.model_hidden_size = 0;
53 self.loading.model_n_ctx_train = 0;
54 self.loading.model_n_head = 0;
55 self.loading.model_n_kv_head = 0;
56 self.loading.vram_estimate = 0;
57 self.settings.spec_type = String::new();
58 self.settings.draft_tokens = 0;
59 self.settings_state.settings_render_cache = None;
60 self.add_log(
61 "Reset LLM Settings to defaults",
62 crate::config::LogLevel::Info,
63 );
64 self.ui.needs_redraw = true;
65 }
66
67 pub fn selected_model(&self) -> Option<&crate::models::DiscoveredModel> {
68 self.selected_model_idx.and_then(|i| self.models.get(i))
69 }
70
71 pub fn selected_model_settings(&self) -> crate::models::ModelSettings {
72 let model_name = self.selected_model().map(|m| m.name.as_str());
73 self.config.resolve_settings(model_name, None)
76 }
77
78 pub fn on_model_selection_change(&mut self) {
79 self.search.readme_cache = None;
80 if let Some(idx) = self.selected_model_idx {
81 let model = self.models[idx].clone();
82 self.model_settings_cache = self.selected_model_settings();
83 self.settings = self.model_settings_cache.clone();
84 self.update_model_metadata();
85 self.update_vram_estimate();
86
87 if self.is_model_loaded(&model.display_name) {
89 self.loading.loading_progress = 1.0;
90 if !self
91 .loading
92 .loading_phases
93 .contains(&super::types::LoadingPhase::Complete)
94 {
95 self.loading
96 .loading_phases
97 .insert(super::types::LoadingPhase::Complete);
98 }
99 } else if matches!(
100 self.model_states.get(&model.display_name),
101 Some(crate::models::ModelState::Loading)
102 | Some(crate::models::ModelState::Benchmarking)
103 ) {
104 } else {
106 self.loading.loading_progress = 0.0;
108 self.loading.loading_phases.clear();
109 self.loading.last_active_phase = None;
110 self.loading.load_progress = Default::default();
111 }
112 } else {
113 let default_params = self.config.default.clone();
114 self.model_settings_cache = default_params.into();
115 self.loading.model_total_layers = 0;
116 self.loading.model_hidden_size = 0;
117 self.loading.model_n_ctx_train = 0;
118 self.settings.spec_type = String::new();
119 self.settings.draft_tokens = 0;
120 self.loading.vram_estimate = 0;
121 self.loading.loading_progress = 0.0;
122 self.loading.loading_phases.clear();
123 self.loading.last_active_phase = None;
124 }
125 self.ui.needs_redraw = true;
126 }
127
128 pub fn search_results_len(&self) -> usize {
130 if let super::types::ModelsMode::Search { results, .. } = &self.models_mode {
131 results.len()
132 } else {
133 0
134 }
135 }
136
137 pub fn get_filtered_model_indices(&self) -> Vec<usize> {
138 self.models
139 .iter()
140 .enumerate()
141 .filter(|(_, m)| {
142 self.search.local_filter.is_empty()
143 || m.display_name
144 .to_lowercase()
145 .contains(&self.search.local_filter.to_lowercase())
146 })
147 .map(|(i, _)| i)
148 .collect()
149 }
150}
151
152fn normalize_separators(s: &str) -> String {
154 s.replace('_', "-")
155}
156
157pub fn model_is_downloaded(models: &[crate::models::DiscoveredModel], model_id: &str) -> bool {
163 let repo_name = model_id
164 .rsplit('/')
165 .next()
166 .unwrap_or(model_id)
167 .to_lowercase();
168 let repo_normalized = normalize_separators(&repo_name);
169 let repo_parts: Vec<&str> = repo_normalized.split('-').collect();
170
171 models.iter().any(|m| {
172 let mut local = m.name.to_lowercase();
173 if let Some(stripped) = local.strip_suffix(".gguf") {
174 local = stripped.to_string();
175 }
176 let local_normalized = normalize_separators(&local);
177
178 if local_normalized == repo_normalized {
180 return true;
181 }
182
183 for i in 1..=repo_parts.len() {
185 let prefix = repo_parts[..i].join("-");
186 if prefix.len() >= 8 && local_normalized.starts_with(&format!("{}-", prefix)) {
187 return true;
188 }
189 }
190 false
191 })
192}
193
194#[allow(dead_code)]
196pub fn file_is_downloaded(models: &[crate::models::DiscoveredModel], filename: &str) -> bool {
197 let target = filename.to_lowercase();
198 models.iter().any(|m| {
199 let local = m.name.to_lowercase();
200 local == target
201 })
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::models::DiscoveredModel;
208
209 fn make_discovered(name: &str) -> DiscoveredModel {
210 DiscoveredModel {
211 path: PathBuf::from(format!("/models/{}", name)),
212 name: name.to_string(),
213 file_size: 0,
214 display_name: name.to_string(),
215 }
216 }
217
218 #[test]
219 fn exact_match() {
220 let models = vec![make_discovered("Qwen2.5-7B-Instruct.gguf")];
221 assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
222 }
223
224 #[test]
225 fn hyphen_separator_match() {
226 let models = vec![make_discovered("Qwen2.5-7B-Instruct-Q4_K_M.gguf")];
227 assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
228 }
229
230 #[test]
231 fn underscore_separator_match() {
232 let models = vec![make_discovered("Qwen2.5_7B_Instruct.gguf")];
233 assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
234 }
235
236 #[test]
237 fn underscore_separator_match_with_quant() {
238 let models = vec![make_discovered("Qwen2.5_7B_Instruct-Q4_K_M.gguf")];
239 assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
240 }
241
242 #[test]
243 fn case_insensitive_match() {
244 let models = vec![make_discovered("qwen2.5-7b-instruct-q4_k_m.gguf")];
245 assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
246 }
247
248 #[test]
249 fn no_match_different_model() {
250 let models = vec![make_discovered("Llama-3.1-8B-Instruct-Q4_K_M.gguf")];
251 assert!(!model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
252 }
253
254 #[test]
255 fn no_match_partial_prefix_false_positive() {
256 let models = vec![make_discovered("Qwen2.5-Mistral-Q4_K_M.gguf")];
257 assert!(!model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
258 }
259
260 #[test]
261 fn no_match_empty_models() {
262 let models: Vec<DiscoveredModel> = vec![];
263 assert!(!model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
264 }
265
266 #[test]
267 fn strip_gguf_extension() {
268 let models = vec![make_discovered("Qwen2.5-7B-Instruct-Q4_K_M.GGUF")];
269 assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
270 }
271
272 #[test]
273 fn no_gguf_extension() {
274 let models = vec![make_discovered("Qwen2.5-7B-Instruct-Q4_K_M")];
275 assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
276 }
277
278 #[test]
279 fn multiple_models() {
280 let models = vec![
281 make_discovered("Llama-3.1-8B-Instruct-Q4_K_M.gguf"),
282 make_discovered("Qwen2.5-7B-Instruct-Q4_K_M.gguf"),
283 make_discovered("Mistral-7B-v0.3-Q8_0.gguf"),
284 ];
285 assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
286 assert!(model_is_downloaded(
287 &models,
288 "meta-llama/Llama-3.1-8B-Instruct"
289 ));
290 assert!(model_is_downloaded(&models, "mistralai/Mistral-7B-v0.3"));
291 assert!(!model_is_downloaded(&models, "google/gemma-2-9b"));
292 }
293
294 #[test]
295 fn repo_name_with_extra_suffix() {
296 let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
299 assert!(model_is_downloaded(&models, "unsloth/Qwen3.6-27B-MTP-GGUF"));
300 }
301
302 #[test]
303 fn repo_name_with_extra_suffix_different_size() {
304 let models = vec![make_discovered("Qwen3.6-7B-Q3_K_S.gguf")];
306 assert!(!model_is_downloaded(
307 &models,
308 "unsloth/Qwen3.6-27B-MTP-GGUF"
309 ));
310 }
311
312 #[test]
313 fn file_exact_match() {
314 let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
315 assert!(file_is_downloaded(&models, "Qwen3.6-27B-Q3_K_S.gguf"));
316 }
317
318 #[test]
319 fn file_exact_match_case_insensitive() {
320 let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
321 assert!(file_is_downloaded(&models, "qwen3.6-27b-q3_k_s.gguf"));
322 }
323
324 #[test]
325 fn file_no_match_different_quant() {
326 let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
327 assert!(!file_is_downloaded(&models, "Qwen3.6-27B-Q4_K_M.gguf"));
328 }
329
330 #[test]
331 fn file_no_match_different_model() {
332 let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
333 assert!(!file_is_downloaded(
334 &models,
335 "Llama-3.1-8B-Instruct-Q4_K_M.gguf"
336 ));
337 }
338
339 #[test]
340 fn file_empty_models() {
341 let models: Vec<DiscoveredModel> = vec![];
342 assert!(!file_is_downloaded(&models, "Qwen3.6-27B-Q3_K_S.gguf"));
343 }
344}