diff --git a/.gitignore b/.gitignore index 46ea128..38c976e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target llmfit +/docs diff --git a/README.md b/README.md index 124415e..4430ac2 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ Example of a medium performance home laptop Example of models with Mixture-of-Experts architectures ![moe](moe.png) + +Downloading a model via Ollama integration + +![download](download.gif) --- ## Install @@ -88,6 +92,10 @@ Launches the interactive terminal UI. Your system specs (CPU, RAM, GPU name, VRA | `Esc` or `Enter` | Exit search mode | | `Ctrl-U` | Clear search | | `f` | Cycle fit filter: All, Runnable, Perfect, Good, Marginal | +| `p` | Open provider filter popup | +| `i` | Toggle installed-first sorting (Ollama only) | +| `d` | Pull/download selected model via Ollama | +| `r` | Refresh installed models from Ollama | | `1`-`9` | Toggle provider visibility | | `Enter` | Toggle detail view for selected model | | `PgUp` / `PgDn` | Scroll by 10 | @@ -225,6 +233,7 @@ src/ hardware.rs -- System RAM/CPU/GPU detection (multi-GPU, backend identification) models.rs -- Model database, quantization hierarchy, dynamic quant selection fit.rs -- Multi-dimensional scoring (Q/S/F/C), speed estimation, MoE offloading + providers.rs -- Runtime provider integration (Ollama), model install detection, pull/download display.rs -- Classic CLI table rendering + JSON output tui_app.rs -- TUI application state, filters, navigation tui_ui.rs -- TUI rendering (ratatui) @@ -288,11 +297,36 @@ cargo publish | `serde` / `serde_json` | JSON deserialization for model database | | `tabled` | CLI table formatting | | `colored` | CLI colored output | +| `ureq` | HTTP client for Ollama API integration | | `ratatui` | Terminal UI framework | | `crossterm` | Terminal input/output backend for ratatui | --- +## Ollama integration + +llmfit integrates with [Ollama](https://ollama.com) to detect which models you already have installed and to download new ones directly from the TUI. + +### Requirements + +- **Ollama must be installed and running** (`ollama serve` or the Ollama desktop app) +- llmfit connects to `http://localhost:11434` (Ollama's default API port) +- No configuration needed — if Ollama is running, llmfit detects it automatically + +### How it works + +On startup, llmfit queries `GET /api/tags` to list your installed Ollama models. Each installed model gets a green **✓** in the **Inst** column of the TUI. The system bar shows `Ollama: ✓ (N installed)`. + +When you press `d` on a model, llmfit sends `POST /api/pull` to Ollama to download it. The row highlights with an animated progress indicator showing download progress in real-time. Once complete, the model is immediately available for use with Ollama. + +If Ollama is not running, the `d`, `i`, and `r` keybindings are hidden from the status bar and disabled — the TUI works normally without Ollama, you just can't see install status or pull models. + +### Model name mapping + +llmfit's database uses HuggingFace model names (e.g. `Qwen/Qwen2.5-Coder-14B-Instruct`) while Ollama uses its own naming scheme (e.g. `qwen2.5-coder:14b`). llmfit maintains an accurate mapping table between the two so that install detection and pulls resolve to the correct model. Each mapping is exact — `qwen2.5-coder:14b` maps to the Coder model, not the base `qwen2.5:14b`. + +--- + ## Platform support - **Linux** -- Full support. GPU detection via `nvidia-smi` (NVIDIA), `rocm-smi` (AMD), and sysfs/`lspci` (Intel Arc). diff --git a/src/fit.rs b/src/fit.rs index 2f781d5..331605d 100644 --- a/src/fit.rs +++ b/src/fit.rs @@ -49,7 +49,7 @@ pub struct ModelFit { pub estimated_tps: f64, // estimated tokens per second pub best_quant: String, // best quantization for this hardware pub use_case: UseCase, // inferred use case category - pub installed: bool, // model found in a local runtime provider + pub installed: bool, // model found in a local runtime provider } impl ModelFit { diff --git a/src/providers.rs b/src/providers.rs index c6b0780..d5fb0ae 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -35,7 +35,10 @@ pub struct PullHandle { #[derive(Debug, Clone)] pub enum PullEvent { - Progress { status: String, percent: Option }, + Progress { + status: String, + percent: Option, + }, Done, Error(String), } @@ -193,11 +196,7 @@ pub fn hf_name_to_ollama_candidates(hf_name: &str) -> Vec { let mut candidates = Vec::new(); // Take the part after the slash (repo name) - let repo = hf_name - .split('/') - .last() - .unwrap_or(hf_name) - .to_lowercase(); + let repo = hf_name.split('/').last().unwrap_or(hf_name).to_lowercase(); // Common provider-specific mappings from HF repo names → Ollama tags. // These are checked first since they're authoritative. @@ -282,7 +281,10 @@ pub fn hf_name_to_ollama_candidates(hf_name: &str) -> Vec { ("granite-4.0-h-small", "granite4.0-h:small"), ("zephyr-7b-beta", "zephyr:7b"), ("c4ai-command-r-v01", "command-r"), - ("nous-hermes-2-mixtral-8x7b-dpo", "nous-hermes2-mixtral:8x7b"), + ( + "nous-hermes-2-mixtral-8x7b-dpo", + "nous-hermes2-mixtral:8x7b", + ), ("nomic-embed-text-v1.5", "nomic-embed-text"), ("bge-large-en-v1.5", "bge-large"), ]; @@ -317,13 +319,10 @@ pub fn is_model_installed(hf_name: &str, installed: &HashSet) -> bool { /// Given an HF model name, return the best Ollama tag to use for pulling. pub fn ollama_pull_tag(hf_name: &str) -> String { let candidates = hf_name_to_ollama_candidates(hf_name); - candidates.into_iter().next().unwrap_or_else(|| { - hf_name - .split('/') - .last() - .unwrap_or(hf_name) - .to_lowercase() - }) + candidates + .into_iter() + .next() + .unwrap_or_else(|| hf_name.split('/').last().unwrap_or(hf_name).to_lowercase()) } #[cfg(test)] @@ -339,7 +338,10 @@ mod tests { installed.insert("qwen2.5-coder:14b".to_string()); installed.insert("qwen2.5-coder".to_string()); - assert!(is_model_installed("Qwen/Qwen2.5-Coder-14B-Instruct", &installed)); + assert!(is_model_installed( + "Qwen/Qwen2.5-Coder-14B-Instruct", + &installed + )); // Must NOT match the non-coder model assert!(!is_model_installed("Qwen/Qwen2.5-14B-Instruct", &installed)); } @@ -353,7 +355,10 @@ mod tests { installed.insert("qwen2.5".to_string()); assert!(is_model_installed("Qwen/Qwen2.5-14B-Instruct", &installed)); - assert!(!is_model_installed("Qwen/Qwen2.5-Coder-14B-Instruct", &installed)); + assert!(!is_model_installed( + "Qwen/Qwen2.5-Coder-14B-Instruct", + &installed + )); } #[test] @@ -376,7 +381,8 @@ mod tests { #[test] fn test_deepseek_coder_mapping() { - let candidates = hf_name_to_ollama_candidates("deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct"); + let candidates = + hf_name_to_ollama_candidates("deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct"); assert!(candidates.contains(&"deepseek-coder-v2:16b".to_string())); } } diff --git a/src/tui_app.rs b/src/tui_app.rs index b369352..d823f16 100644 --- a/src/tui_app.rs +++ b/src/tui_app.rs @@ -1,7 +1,7 @@ use crate::fit::{FitLevel, ModelFit}; use crate::hardware::SystemSpecs; use crate::models::ModelDatabase; -use crate::providers::{self, OllamaProvider, ModelProvider, PullHandle, PullEvent}; +use crate::providers::{self, ModelProvider, OllamaProvider, PullEvent, PullHandle}; use std::collections::HashSet; use std::sync::mpsc; @@ -341,7 +341,9 @@ impl App { if self.pull_active.is_some() { return; // already pulling } - let Some(fit) = self.selected_fit() else { return }; + let Some(fit) = self.selected_fit() else { + return; + }; if fit.installed { self.pull_status = Some("Already installed".to_string()); return; @@ -366,7 +368,9 @@ impl App { if self.pull_active.is_some() { self.tick_count = self.tick_count.wrapping_add(1); } - let Some(handle) = &self.pull_active else { return }; + let Some(handle) = &self.pull_active else { + return; + }; // Drain all available events loop { match handle.receiver.try_recv() { diff --git a/src/tui_ui.rs b/src/tui_ui.rs index 337f92d..ecf43af 100644 --- a/src/tui_ui.rs +++ b/src/tui_ui.rs @@ -85,7 +85,11 @@ fn draw_system_bar(frame: &mut Frame, app: &App, area: Rect) { } else { "Ollama: ✗".to_string() }; - let ollama_color = if app.ollama_available { Color::Green } else { Color::DarkGray }; + let ollama_color = if app.ollama_available { + Color::Green + } else { + Color::DarkGray + }; let text = Line::from(vec![ Span::styled(" CPU: ", Style::default().fg(Color::DarkGray)), @@ -265,8 +269,8 @@ fn pull_indicator(percent: Option, tick: u64) -> String { fn draw_table(frame: &mut Frame, app: &mut App, area: Rect) { let header_cells = [ - "", "Inst", "Model", "Provider", "Params", "Score", "tok/s", "Quant", "Mode", "Mem %", "Ctx", - "Fit", "Use Case", + "", "Inst", "Model", "Provider", "Params", "Score", "tok/s", "Quant", "Mode", "Mem %", + "Ctx", "Fit", "Use Case", ] .iter() .map(|h| { @@ -479,7 +483,10 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect) { if fit.installed { Span::styled("✓ Yes (Ollama)", Style::default().fg(Color::Green).bold()) } else if app.ollama_available { - Span::styled("✗ No (press d to pull)", Style::default().fg(Color::DarkGray)) + Span::styled( + "✗ No (press d to pull)", + Style::default().fg(Color::DarkGray), + ) } else { Span::styled("- Ollama not running", Style::default().fg(Color::DarkGray)) }, @@ -814,9 +821,17 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { let (keys, mode_text) = match app.input_mode { InputMode::Normal => { - let detail_key = if app.show_detail { "Enter:table" } else { "Enter:detail" }; + let detail_key = if app.show_detail { + "Enter:table" + } else { + "Enter:detail" + }; let ollama_keys = if app.ollama_available { - let installed_key = if app.installed_first { "i:all" } else { "i:installed↑" }; + let installed_key = if app.installed_first { + "i:all" + } else { + "i:installed↑" + }; format!(" {} d:pull r:refresh", installed_key) } else { String::new() @@ -824,13 +839,15 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { ( format!( " ↑↓/jk:nav {} /:search f:fit{} p:providers q:quit", - detail_key, - ollama_keys, + detail_key, ollama_keys, ), "NORMAL", ) } - InputMode::Search => (" Type to search Esc:done Ctrl-U:clear".to_string(), "SEARCH"), + InputMode::Search => ( + " Type to search Esc:done Ctrl-U:clear".to_string(), + "SEARCH", + ), InputMode::ProviderPopup => ( " ↑↓/jk:navigate Space:toggle a:all/none Esc:close".to_string(), "PROVIDERS", @@ -840,7 +857,10 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { // Split into two lines: keys + progress let chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Length(progress_text.len() as u16 + 2)]) + .constraints([ + Constraint::Min(20), + Constraint::Length(progress_text.len() as u16 + 2), + ]) .split(area); let status_line = Line::from(vec![ @@ -852,9 +872,16 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { ]); frame.render_widget(Paragraph::new(status_line), chunks[0]); - let pull_color = if app.pull_active.is_some() { Color::Yellow } else { Color::Green }; + let pull_color = if app.pull_active.is_some() { + Color::Yellow + } else { + Color::Green + }; frame.render_widget( - Paragraph::new(Line::from(Span::styled(progress_text, Style::default().fg(pull_color)))), + Paragraph::new(Line::from(Span::styled( + progress_text, + Style::default().fg(pull_color), + ))), chunks[1], ); return; @@ -868,7 +895,11 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { "Enter:detail" }; let ollama_keys = if app.ollama_available { - let installed_key = if app.installed_first { "i:all" } else { "i:installed↑" }; + let installed_key = if app.installed_first { + "i:all" + } else { + "i:installed↑" + }; format!(" {} d:pull r:refresh", installed_key) } else { String::new() @@ -876,8 +907,7 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { ( format!( " ↑↓/jk:nav {} /:search f:fit{} p:providers q:quit", - detail_key, - ollama_keys, + detail_key, ollama_keys, ), "NORMAL", )