try to use local ai to generate commit message
This commit is contained in:
Generated
+3528
-41
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,23 @@
|
||||
name = "git_lite"
|
||||
version = "0.1.0"
|
||||
description = "Rust backend for a lightweight Git desktop client"
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
build = "build.rs"
|
||||
|
||||
[workspace]
|
||||
members = [".", "crates/commit_ai"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "=2.7.0"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||
commit_ai = { path = "crates/commit_ai" }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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 8–32k 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)
|
||||
}
|
||||
@@ -479,6 +479,37 @@ pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String
|
||||
Ok(String::from_utf8_lossy(&output).to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_status(
|
||||
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
||||
) -> Result<commit_ai::CommitAiStatus, String> {
|
||||
Ok(engine.status().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_generate(
|
||||
path: String,
|
||||
notes: Option<String>,
|
||||
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
||||
) -> Result<String, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let diff = run_git(
|
||||
&repo,
|
||||
[
|
||||
"diff",
|
||||
"--cached",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--unified=3",
|
||||
],
|
||||
)?;
|
||||
let diff = String::from_utf8_lossy(&diff).to_string();
|
||||
|
||||
engine
|
||||
.generate_commit_message(&diff, notes.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_file_patch(
|
||||
path: String,
|
||||
@@ -1573,6 +1604,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
let (branch, mut files) = parse_status_output(&output)?;
|
||||
detect_worktree_renames(repo, &mut files);
|
||||
|
||||
|
||||
Ok(GitStatus {
|
||||
repo_path: repo.to_string_lossy().to_string(),
|
||||
current_branch: branch.current_branch,
|
||||
|
||||
+21
-7
@@ -4,21 +4,33 @@ mod git;
|
||||
|
||||
use git::{
|
||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit,
|
||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete,
|
||||
cred_load, cred_save, delete_branch, diff_file_against_working_tree, get_file_patch,
|
||||
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
||||
list_repository_files, merge_branch, open_repo_in_explorer, open_repository,
|
||||
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
|
||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||
commit_ai_generate, commit_ai_status, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
|
||||
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
|
||||
list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
|
||||
open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict,
|
||||
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||
SearchCancellationState,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let commit_ai_engine = commit_ai::CommitAiEngine::new();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(SearchCancellationState::default())
|
||||
.manage(commit_ai_engine.clone())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(move |_app| {
|
||||
// Kick off the (first-run-only) download and model load in the background so the
|
||||
// "Generate with AI" button becomes enabled once it's ready, without blocking startup.
|
||||
let engine = commit_ai_engine.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
engine.ensure_loaded().await;
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
open_repo_in_explorer,
|
||||
@@ -35,6 +47,8 @@ fn main() {
|
||||
get_file_patch,
|
||||
apply_file_patch,
|
||||
commit,
|
||||
commit_ai_status,
|
||||
commit_ai_generate,
|
||||
pull,
|
||||
push,
|
||||
list_commits,
|
||||
|
||||
Reference in New Issue
Block a user