Skip to main content

llm_manager/tui/app/
sync_ops.rs

1use 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        // Clear dirty flag by updating the cache snapshot to match new settings
49        self.model_settings_cache = self.settings.clone();
50        // Reset model metadata to avoid stale values
51        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        // For the TUI, we don't currently support a separate profile_name
74        // in this method since it's already accounted for in overrides or the default settings.
75        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            // Sync loading progress with the newly selected model
88            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                // Keep current loading/benchmarking progress
105            } else {
106                // Not loaded, loading, or benchmarking, reset progress
107                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    /// Return the current number of search results.
129    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
152/// Normalize separator characters for comparison (dashes and underscores).
153fn normalize_separators(s: &str) -> String {
154    s.replace('_', "-")
155}
156
157/// Check if a model (identified by HF model_id) is already downloaded locally.
158/// Matches by comparing the HF repo name against local filenames, case-insensitively.
159/// Checks all prefixes of the repo name to handle cases where the repo name has
160/// extra components not present in the local filename (e.g. "Qwen3.6-27B-MTP-GGUF"
161/// vs local "Qwen3.6-27B-Q3_K_S.gguf").
162pub 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        // Check exact match first
179        if local_normalized == repo_normalized {
180            return true;
181        }
182
183        // Check if local starts with any prefix of the repo name (minimum 8 chars to avoid false positives)
184        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/// Check if a GGUF filename is already downloaded locally (exact match).
195#[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        // HF repo: unsloth/Qwen3.6-27B-MTP-GGUF, local file: Qwen3.6-27B-Q3_K_S.gguf
297        // The repo name has "MTP-GGUF" suffix not in the local filename
298        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        // Should NOT match: repo is 27B, local is 7B
305        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}