mod cloud; pub use cloud::{generate_anthropic, generate_custom, generate_openai}; use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, sync::Arc, }; use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole}; use tokio::sync::RwLock; /// One selectable local (on-device) model. Larger models produce better commit messages /// but take longer to download (first run only, then cached) and run slower on CPU. #[derive(Debug, Clone, serde::Serialize)] pub struct LocalModelOption { pub id: &'static str, pub label: &'static str, pub approx_size_mb: u32, repo: &'static str, file: &'static str, tokenizer_repo: &'static str, } pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b"; pub const LOCAL_MODELS: &[LocalModelOption] = &[ LocalModelOption { id: "qwen2.5-0.5b", label: "Qwen2.5 0.5B Instruct — fast, lower quality", approx_size_mb: 490, repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF", file: "qwen2.5-0.5b-instruct-q4_k_m.gguf", tokenizer_repo: "Qwen/Qwen2.5-0.5B-Instruct", }, LocalModelOption { id: "qwen2.5-1.5b", label: "Qwen2.5 1.5B Instruct — recommended", approx_size_mb: 1050, repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF", file: "qwen2.5-1.5b-instruct-q4_k_m.gguf", tokenizer_repo: "Qwen/Qwen2.5-1.5B-Instruct", }, LocalModelOption { id: "qwen2.5-3b", label: "Qwen2.5 3B Instruct — best quality, slower", approx_size_mb: 2100, repo: "Qwen/Qwen2.5-3B-Instruct-GGUF", file: "qwen2.5-3b-instruct-q4_k_m.gguf", tokenizer_repo: "Qwen/Qwen2.5-3B-Instruct", }, ]; fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> { LOCAL_MODELS.iter().find(|option| option.id == model_id) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum LocalGenerationProfile { Fast, Balanced, Detailed, } impl Default for LocalGenerationProfile { fn default() -> Self { Self::Fast } } impl LocalGenerationProfile { pub fn from_id(value: Option<&str>) -> Self { match value .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str() { "balanced" => Self::Balanced, "detailed" => Self::Detailed, _ => Self::Fast, } } pub fn diff_unified_context(self) -> &'static str { match self { Self::Fast => "--unified=1", Self::Balanced => "--unified=2", Self::Detailed => "--unified=3", } } fn max_diff_chars(self) -> usize { match self { Self::Fast => 8_000, Self::Balanced => 12_000, Self::Detailed => 24_000, } } fn max_output_tokens(self) -> usize { match self { Self::Fast => 160, Self::Balanced => 360, Self::Detailed => 750, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "lowercase")] pub enum CommitAiPhase { /// Nothing has been requested yet. Idle, /// Downloading (first run only, then cached by hf-hub) and/or loading into memory. Loading, Ready, Error, } #[derive(Debug, Clone, serde::Serialize)] pub struct CommitAiStatus { pub phase: CommitAiPhase, pub model_id: Option, pub error: Option, } struct Inner { phase: CommitAiPhase, model_id: Option, error: Option, model: Option>, cache: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct GenerationCacheKey { model_id: String, profile: LocalGenerationProfile, input_hash: u64, } #[derive(Debug, Clone)] struct GenerationCache { key: GenerationCacheKey, message: String, } /// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls /// (see [`cloud`]) and don't need this — there's nothing to download or keep loaded. #[derive(Clone)] pub struct CommitAiEngine { inner: Arc>, } impl Default for CommitAiEngine { fn default() -> Self { Self { inner: Arc::new(RwLock::new(Inner { phase: CommitAiPhase::Idle, model_id: None, error: None, model: None, cache: None, })), } } } impl CommitAiEngine { pub fn new() -> Self { Self::default() } pub async fn status(&self) -> CommitAiStatus { let guard = self.inner.read().await; CommitAiStatus { phase: guard.phase, model_id: guard.model_id.clone(), error: guard.error.clone(), } } /// Downloads (first run only; hf-hub caches the files afterwards) and loads the given /// local model. Safe to call repeatedly — a call for the model that's already /// ready/loading is a no-op; a call for a *different* model switches to it (the /// previous one is dropped once no generation is still using it). pub async fn ensure_loaded(&self, model_id: &str) { { let guard = self.inner.read().await; let same_model = guard.model_id.as_deref() == Some(model_id); if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) { return; } } let Some(option) = find_local_model(model_id) else { let mut guard = self.inner.write().await; guard.phase = CommitAiPhase::Error; guard.model_id = Some(model_id.to_string()); guard.error = Some(format!("Unknown local model: {model_id}")); guard.cache = None; return; }; { let mut guard = self.inner.write().await; guard.phase = CommitAiPhase::Loading; guard.model_id = Some(model_id.to_string()); guard.error = None; guard.model = None; guard.cache = None; } let result = GgufModelBuilder::new(option.repo, vec![option.file]) .with_tok_model_id(option.tokenizer_repo) .with_logging() .build() .await; let mut guard = self.inner.write().await; // If the user switched to yet another model while this one was loading, drop this // (now stale) result instead of overwriting the newer request's state. if guard.model_id.as_deref() != Some(model_id) { return; } match result { Ok(model) => { guard.model = Some(Arc::new(model)); guard.phase = CommitAiPhase::Ready; guard.error = None; guard.cache = None; } Err(err) => { guard.phase = CommitAiPhase::Error; guard.error = Some(err.to_string()); guard.cache = None; } } } pub async fn generate_commit_message( &self, diff: &str, notes: Option<&str>, profile: LocalGenerationProfile, ) -> Result { let (model, cache_key) = { let guard = self.inner.read().await; match (guard.phase, &guard.model) { (CommitAiPhase::Ready, Some(model)) => { let cache_key = GenerationCacheKey { model_id: guard.model_id.clone().unwrap_or_default(), profile, input_hash: generation_input_hash(diff, notes), }; if let Some(cache) = &guard.cache { if cache.key == cache_key { return Ok(cache.message.clone()); } } (model.clone(), cache_key) } _ => return Err("The local AI model is not ready yet.".to_string()), } }; let (system, user) = build_local_messages(diff, notes, profile)?; let request = RequestBuilder::new() .set_sampler_max_len(profile.max_output_tokens()) .add_message(TextMessageRole::System, system) .add_message(TextMessageRole::User, user); let response = model .send_chat_request(request) .await .map_err(|err| err.to_string())?; let content = response .choices .first() .and_then(|choice| choice.message.content.clone()) .ok_or_else(|| "The model did not return a response.".to_string())?; let message = sanitize_message(&content); if message.is_empty() { return Err("The model did not return a response.".to_string()); } if looks_like_diff_echo(&message) { return Err( "The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(), ); } { let mut guard = self.inner.write().await; guard.cache = Some(GenerationCache { key: cache_key, message: message.clone(), }); } Ok(message) } } fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 { let mut hasher = DefaultHasher::new(); diff.hash(&mut hasher); notes.unwrap_or("").hash(&mut hasher); hasher.finish() } /// Models occasionally ignore the "no code fences" instruction (small local models /// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go /// straight into the commit-message box. pub(crate) fn sanitize_message(raw: &str) -> String { let mut text = raw.trim().to_string(); if text.starts_with("```") { text = match text.split_once('\n') { // Drop the opening fence line (which may carry a language tag) and the closing fence. Some((_fence, rest)) => rest.trim_end().trim_end_matches("```").trim().to_string(), None => text.trim_matches('`').trim().to_string(), }; } let trimmed = text.trim(); if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { return trimmed[1..trimmed.len() - 1].trim().to_string(); } trimmed.to_string() } /// Weak models (small local ones especially) sometimes just echo the prompt's diff /// sections back instead of writing a commit message. Catch that so the UI can show a /// clear error instead of dumping raw diff text into the commit-message box. pub(crate) fn looks_like_diff_echo(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("diff --git") || lower.contains("staged files:") || lower.contains("staged changes:") || lower.contains("diff stat:") || lower.contains("detailed diff:") || message.lines().any(|line| line.starts_with("@@ ")) } fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String { if input.len() <= max_chars { return input.to_string(); } let mut cut = max_chars; while !input.is_char_boundary(cut) { cut -= 1; } format!("{}\n\n[... diff truncated ...]", &input[..cut]) } pub(crate) fn build_local_messages( diff: &str, notes: Option<&str>, profile: LocalGenerationProfile, ) -> Result<(String, String), String> { if diff.trim().is_empty() { return Err("No staged changes available for a commit message.".to_string()); } let diff = truncate_at_char_boundary(diff, profile.max_diff_chars()); // Appended to every profile below: small local models occasionally just echo the input // (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's // `staged_diff_local`) instead of writing a new commit message. Naming those exact // section headers here makes the failure mode explicit enough for weak models to avoid. const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \ do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \ anywhere in your answer."; let system = match profile { LocalGenerationProfile::Fast => { format!( "You generate Git commit messages. Respond only with one Conventional Commits subject line: (): . Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}" ) } LocalGenerationProfile::Balanced => { format!( "You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}" ) } LocalGenerationProfile::Detailed => { format!( "You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}" ) } }; let mut user = String::new(); if let Some(n) = notes.filter(|n| !n.trim().is_empty()) { user.push_str(&format!("Developer notes:\n{n}\n\n")); } user.push_str(&format!("Staged changes:\n{diff}")); Ok((system, user)) } pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> { if diff.trim().is_empty() { return Err("No staged changes available for a commit message.".to_string()); } // Rough token estimate — small models often have an 8-32k context window. const MAX_CHARS: usize = 24_000; let diff = truncate_at_char_boundary(diff, MAX_CHARS); let system = "You are a tool that writes a Git commit message describing a staged diff. \ Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \ do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \ or 'Diff stat:' anywhere in your answer. \ Format: a Conventional Commits header ((): ) in imperative mood, \ max. 72 characters, then a blank line, then a body. \ The body is required: a short, general paragraph (2-4 sentences) summarizing what changed \ and why at a high level — do NOT enumerate every changed file individually. \ You may optionally add up to 3 bullet points (- ...) afterward, but only for the most \ significant changes overall, never one bullet or heading per file. \ Never use bold text, backticks, or markdown headings for file names. \ Lines in the body max. 72 characters. \ No preamble, no explanation, no code fences, answer in English.\n\n\ Example:\n\ Diff:\n\ diff --git a/src/auth.py b/src/auth.py\n\ +def hash_password(pw):\n\ + return bcrypt.hash(pw)\n\ diff --git a/src/routes.py b/src/routes.py\n\ -if password == stored_password:\n\ +if bcrypt.check(password, stored_password):\n\n\ Commit message:\n\ feat(auth): hash and verify passwords with bcrypt\n\n\ Passwords were previously compared as plain text. This adds a bcrypt-based\n\ hashing helper and updates the login check to verify against the hash\n\ instead of a direct string comparison.\n\n\ - Hash passwords on write, verify with bcrypt on login" .to_string(); let mut user = String::new(); if let Some(n) = notes.filter(|n| !n.trim().is_empty()) { user.push_str(&format!("Developer notes:\n{n}\n\n")); } user.push_str(&format!("Staged diff:\n{diff}")); Ok((system, user)) }