feat(ai): add cloud providers and local model selection
Add OpenAI-compatible, Anthropic, and custom endpoint support while
keeping the local model path intact. The UI now lets users choose the
provider and local model, and staged diffs are prepared more carefully
so generated commit messages stay focused and usable.
- src-tauri/crates/commit_ai/*
- Add HTTP-based generators for OpenAI, Anthropic, and custom APIs.
- Introduce shared request/response handling and message sanitizing.
- Expand prompt building to require a body and trim long diffs safely.
- Expose selectable local model metadata and loading by model ID.
- src-tauri/src/git.rs
- Add commands for listing local models and loading them in background.
- Route generation by provider and include staged file lists in prompts.
- Exclude noisy lockfiles from detailed staged diffs.
- src-tauri/src/main.rs
- Wire the new AI commands into the Tauri app setup.
- src/lib/components/*
- Add an AI settings dialog and update the commit panel for provider
and model selection.
- src/lib/git.ts, src/lib/types.ts, src/App.svelte, src/app.css
- Extend frontend state, types, and styling for AI provider settings.
- src-tauri/Cargo.lock, src-tauri/crates/commit_ai/Cargo.toml
- Add reqwest and serde_json for cloud API requests.
This commit is contained in:
+79
-11
@@ -479,6 +479,11 @@ pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String
|
||||
Ok(String::from_utf8_lossy(&output).to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn commit_ai_local_models() -> Vec<commit_ai::LocalModelOption> {
|
||||
commit_ai::LOCAL_MODELS.to_vec()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_status(
|
||||
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
||||
@@ -486,28 +491,91 @@ pub async fn commit_ai_status(
|
||||
Ok(engine.status().await)
|
||||
}
|
||||
|
||||
/// Kicks off the (first-run-only) download and model load in the background and returns
|
||||
/// immediately; the frontend polls `commit_ai_status` to know when it's ready.
|
||||
#[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)?;
|
||||
pub fn commit_ai_load(model_id: String, engine: tauri::State<'_, commit_ai::CommitAiEngine>) {
|
||||
let engine = engine.inner().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
engine.ensure_loaded(&model_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
// The prompt is built from `git diff --cached` only, i.e. exactly the staged changes —
|
||||
// unstaged edits and untracked files never influence the generated message.
|
||||
fn staged_diff(repo: &Path) -> Result<String, String> {
|
||||
// Full staged file list (nothing excluded) so the model knows the complete scope
|
||||
// even when the detailed diff below is filtered or truncated for context size.
|
||||
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
|
||||
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
|
||||
|
||||
// Generated lockfiles say nothing useful about intent but easily blow the small
|
||||
// context window of local models, so keep them out of the detailed diff.
|
||||
let diff = run_git(
|
||||
&repo,
|
||||
repo,
|
||||
[
|
||||
"diff",
|
||||
"--cached",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--unified=3",
|
||||
"--",
|
||||
".",
|
||||
":(exclude)*package-lock.json",
|
||||
":(exclude)*pnpm-lock.yaml",
|
||||
":(exclude)*yarn.lock",
|
||||
":(exclude)*bun.lockb",
|
||||
":(exclude)*Cargo.lock",
|
||||
":(exclude)*composer.lock",
|
||||
":(exclude)*Gemfile.lock",
|
||||
":(exclude)*poetry.lock",
|
||||
":(exclude)*go.sum",
|
||||
],
|
||||
)?;
|
||||
let diff = String::from_utf8_lossy(&diff).to_string();
|
||||
let diff = String::from_utf8_lossy(&diff);
|
||||
|
||||
engine
|
||||
.generate_commit_message(&diff, notes.as_deref())
|
||||
.await
|
||||
if file_list.is_empty() {
|
||||
return Ok(diff.to_string());
|
||||
}
|
||||
Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_generate(
|
||||
path: String,
|
||||
notes: Option<String>,
|
||||
provider: String,
|
||||
model: Option<String>,
|
||||
api_key: Option<String>,
|
||||
base_url: Option<String>,
|
||||
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
||||
) -> Result<String, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let diff = staged_diff(&repo)?;
|
||||
let notes = notes.as_deref();
|
||||
let model = model.filter(|value| !value.trim().is_empty());
|
||||
let api_key = api_key.filter(|value| !value.trim().is_empty());
|
||||
let base_url = base_url.filter(|value| !value.trim().is_empty());
|
||||
|
||||
match provider.as_str() {
|
||||
"local" => engine.generate_commit_message(&diff, notes).await,
|
||||
"openai" => {
|
||||
let api_key = api_key.ok_or_else(|| "OpenAI-API-Key fehlt.".to_string())?;
|
||||
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
||||
commit_ai::generate_openai(&api_key, &model, &diff, notes).await
|
||||
}
|
||||
"anthropic" => {
|
||||
let api_key = api_key.ok_or_else(|| "Anthropic-API-Key fehlt.".to_string())?;
|
||||
let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string());
|
||||
commit_ai::generate_anthropic(&api_key, &model, &diff, notes).await
|
||||
}
|
||||
"custom" => {
|
||||
let base_url = base_url.ok_or_else(|| "Endpoint-URL fehlt.".to_string())?;
|
||||
let model = model.ok_or_else(|| "Modellname fehlt.".to_string())?;
|
||||
commit_ai::generate_custom(&base_url, api_key.as_deref(), &model, &diff, notes).await
|
||||
}
|
||||
other => Err(format!("Unbekannter KI-Provider: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+11
-20
@@ -4,33 +4,22 @@ mod git;
|
||||
|
||||
use git::{
|
||||
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
||||
checkout_branch, commit, 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,
|
||||
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
|
||||
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,
|
||||
};
|
||||
|
||||
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())
|
||||
.manage(commit_ai::CommitAiEngine::new())
|
||||
.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,
|
||||
@@ -48,6 +37,8 @@ fn main() {
|
||||
apply_file_patch,
|
||||
commit,
|
||||
commit_ai_status,
|
||||
commit_ai_load,
|
||||
commit_ai_local_models,
|
||||
commit_ai_generate,
|
||||
pull,
|
||||
push,
|
||||
|
||||
Reference in New Issue
Block a user