try to use local ai to generate commit message

This commit is contained in:
Christoph Brandau
2026-07-02 19:59:16 +02:00
parent 2a96e79d27
commit f2aa48d2ec
13 changed files with 3889 additions and 62 deletions
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "commit_ai"
version = "0.1.0"
edition = "2024"
[dependencies]
mistralrs = "0.8"
tokio = { version = "1", features = ["sync"] }
serde = { version = "1", features = ["derive"] }
+157
View File
@@ -0,0 +1,157 @@
use std::sync::Arc;
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
use tokio::sync::RwLock;
// Small instruct model, quantized to keep the one-time download reasonable (~1 GB) while
// still being fast enough for short, structured generations like a commit message on CPU.
const HF_REPO: &str = "Qwen/Qwen2.5-1.5B-Instruct-GGUF";
const GGUF_FILE: &str = "qwen2.5-1.5b-instruct-q4_k_m.gguf";
const TOKENIZER_MODEL_ID: &str = "Qwen/Qwen2.5-1.5B-Instruct";
#[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 error: Option<String>,
}
struct Inner {
phase: CommitAiPhase,
error: Option<String>,
model: Option<Arc<Model>>,
}
/// Cheap to clone: shares one model instance across the app via an inner `Arc`.
#[derive(Clone)]
pub struct CommitAiEngine {
inner: Arc<RwLock<Inner>>,
}
impl Default for CommitAiEngine {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(Inner {
phase: CommitAiPhase::Idle,
error: None,
model: 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,
error: guard.error.clone(),
}
}
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the model.
/// Safe to call multiple times — only the first caller actually triggers a load, later
/// callers just return once the in-flight or previous attempt is done.
pub async fn ensure_loaded(&self) {
{
let mut guard = self.inner.write().await;
if guard.phase != CommitAiPhase::Idle {
return;
}
guard.phase = CommitAiPhase::Loading;
guard.error = None;
}
let result = GgufModelBuilder::new(HF_REPO, vec![GGUF_FILE])
.with_tok_model_id(TOKENIZER_MODEL_ID)
.with_logging()
.build()
.await;
let mut guard = self.inner.write().await;
match result {
Ok(model) => {
guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready;
guard.error = None;
}
Err(err) => {
guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string());
}
}
}
pub async fn generate_commit_message(
&self,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
let model = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(),
_ => return Err("Das KI-Modell ist noch nicht bereit.".to_string()),
}
};
if diff.trim().is_empty() {
return Err("Keine gestagten Änderungen für eine Commit-Message vorhanden.".to_string());
}
let (system, user) = build_messages(diff, notes);
let messages = TextMessages::new()
.add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user);
let response = model
.send_chat_request(messages)
.await
.map_err(|err| err.to_string())?;
let content = response
.choices
.first()
.and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string())?;
Ok(content.trim().to_string())
}
}
fn build_messages(diff: &str, notes: Option<&str>) -> (String, String) {
// grobe Token-Schätzung, kleine Modelle haben oft 832k Kontext
const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS {
format!("{}\n\n[... Diff gekürzt ...]", &diff[..MAX_CHARS])
} else {
diff.to_string()
};
let system = "Du bist ein Werkzeug, das Git-Commit-Messages erzeugt. \
Antworte ausschließlich mit der Commit-Message im Conventional-Commits-Format \
(<type>(<scope>): <subject>), optional gefolgt von einem Body nach einer Leerzeile. \
Subject imperativ, max. 72 Zeichen. Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten"
.to_string();
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Anmerkungen des Entwicklers:\n{n}\n\n"));
}
user.push_str(&format!("Staged diff:\n{diff}"));
(system, user)
}