diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3356e50..aba8357 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -48,7 +48,17 @@ "Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")", "Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")", "Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)", - "Bash(kill 24343 24363 24375 24376)" + "Bash(kill 24343 24363 24375 24376)", + "Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-0.5B-Instruct-GGUF?blobs=true\")", + "Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF?blobs=true\")", + "Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF\")", + "Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF\")", + "Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF?blobs=true\")", + "Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct\")", + "Bash(: *)", + "Bash(exit 0 *)", + "Bash(rustc -O sanitize_test.rs -o sanitize_test)", + "Bash(./sanitize_test)" ] } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 945e55a..1048794 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1028,7 +1028,9 @@ name = "commit_ai" version = "0.1.0" dependencies = [ "mistralrs", + "reqwest 0.12.28", "serde", + "serde_json", "tokio", ] diff --git a/src-tauri/crates/commit_ai/Cargo.toml b/src-tauri/crates/commit_ai/Cargo.toml index 09b00e1..501deb3 100644 --- a/src-tauri/crates/commit_ai/Cargo.toml +++ b/src-tauri/crates/commit_ai/Cargo.toml @@ -8,3 +8,5 @@ edition = "2024" mistralrs = "0.8" tokio = { version = "1", features = ["sync"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/src-tauri/crates/commit_ai/src/cloud.rs b/src-tauri/crates/commit_ai/src/cloud.rs new file mode 100644 index 0000000..39377e3 --- /dev/null +++ b/src-tauri/crates/commit_ai/src/cloud.rs @@ -0,0 +1,203 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::{build_messages, sanitize_message}; + +// Großzügig bemessen, damit ein ausführlicher Body mit Stichpunkten nicht abgeschnitten wird. +const DEFAULT_MAX_TOKENS: u32 = 1500; +// Ohne Timeout würde ein hängender Endpoint den "AI"-Button dauerhaft blockieren. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +fn http_client() -> Result { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|err| format!("HTTP-Client konnte nicht erstellt werden: {err}")) +} + +#[derive(Serialize)] +struct OpenAiMessage { + role: &'static str, + content: String, +} + +#[derive(Serialize)] +struct OpenAiRequest { + model: String, + messages: Vec, + temperature: f32, +} + +#[derive(Deserialize)] +struct OpenAiResponseMessage { + content: Option, +} + +#[derive(Deserialize)] +struct OpenAiChoice { + message: OpenAiResponseMessage, +} + +#[derive(Deserialize)] +struct OpenAiResponse { + #[serde(default)] + choices: Vec, +} + +async fn openai_compatible_request( + url: String, + bearer: Option<&str>, + model: &str, + diff: &str, + notes: Option<&str>, +) -> Result { + let (system, user) = build_messages(diff, notes)?; + let body = OpenAiRequest { + model: model.to_string(), + messages: vec![ + OpenAiMessage { role: "system", content: system }, + OpenAiMessage { role: "user", content: user }, + ], + temperature: 0.3, + }; + + let client = http_client()?; + let mut request = client.post(url).json(&body); + if let Some(key) = bearer.filter(|k| !k.trim().is_empty()) { + request = request.bearer_auth(key); + } + + let response = request + .send() + .await + .map_err(|err| format!("Anfrage an das KI-Modell fehlgeschlagen: {err}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|err| format!("Antwort konnte nicht gelesen werden: {err}"))?; + + if !status.is_success() { + return Err(format!("API-Fehler ({status}): {text}")); + } + + let parsed: OpenAiResponse = serde_json::from_str(&text) + .map_err(|err| format!("Antwort konnte nicht verarbeitet werden: {err}"))?; + + parsed + .choices + .into_iter() + .next() + .and_then(|choice| choice.message.content) + .map(|content| sanitize_message(&content)) + .filter(|content| !content.is_empty()) + .ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string()) +} + +pub async fn generate_openai( + api_key: &str, + model: &str, + diff: &str, + notes: Option<&str>, +) -> Result { + if api_key.trim().is_empty() { + return Err("OpenAI-API-Key fehlt.".to_string()); + } + openai_compatible_request( + "https://api.openai.com/v1/chat/completions".to_string(), + Some(api_key), + model, + diff, + notes, + ) + .await +} + +pub async fn generate_custom( + base_url: &str, + api_key: Option<&str>, + model: &str, + diff: &str, + notes: Option<&str>, +) -> Result { + if base_url.trim().is_empty() { + return Err("Endpoint-URL fehlt.".to_string()); + } + let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); + openai_compatible_request(url, api_key, model, diff, notes).await +} + +#[derive(Serialize)] +struct AnthropicMessage { + role: &'static str, + content: String, +} + +#[derive(Serialize)] +struct AnthropicRequest { + model: String, + max_tokens: u32, + system: String, + messages: Vec, +} + +#[derive(Deserialize)] +struct AnthropicContentBlock { + #[serde(default)] + text: Option, +} + +#[derive(Deserialize)] +struct AnthropicResponse { + #[serde(default)] + content: Vec, +} + +pub async fn generate_anthropic( + api_key: &str, + model: &str, + diff: &str, + notes: Option<&str>, +) -> Result { + if api_key.trim().is_empty() { + return Err("Anthropic-API-Key fehlt.".to_string()); + } + let (system, user) = build_messages(diff, notes)?; + let body = AnthropicRequest { + model: model.to_string(), + max_tokens: DEFAULT_MAX_TOKENS, + system, + messages: vec![AnthropicMessage { role: "user", content: user }], + }; + + let client = http_client()?; + let response = client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .json(&body) + .send() + .await + .map_err(|err| format!("Anfrage an Anthropic fehlgeschlagen: {err}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|err| format!("Antwort konnte nicht gelesen werden: {err}"))?; + + if !status.is_success() { + return Err(format!("API-Fehler ({status}): {text}")); + } + + let parsed: AnthropicResponse = serde_json::from_str(&text) + .map_err(|err| format!("Antwort konnte nicht verarbeitet werden: {err}"))?; + + parsed + .content + .into_iter() + .find_map(|block| block.text) + .map(|text| sanitize_message(&text)) + .filter(|text| !text.is_empty()) + .ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string()) +} diff --git a/src-tauri/crates/commit_ai/src/lib.rs b/src-tauri/crates/commit_ai/src/lib.rs index 20ef80a..6ada392 100644 --- a/src-tauri/crates/commit_ai/src/lib.rs +++ b/src-tauri/crates/commit_ai/src/lib.rs @@ -1,13 +1,56 @@ +mod cloud; + +pub use cloud::{generate_anthropic, generate_custom, generate_openai}; + 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"; +/// 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-1.5b"; + +pub const LOCAL_MODELS: &[LocalModelOption] = &[ + LocalModelOption { + id: "qwen2.5-0.5b", + label: "Qwen2.5 0.5B Instruct — schnell, geringere Qualität", + 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 — empfohlen", + 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 — beste Qualität, langsamer", + 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, serde::Serialize)] #[serde(rename_all = "lowercase")] @@ -23,16 +66,19 @@ pub enum CommitAiPhase { #[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>, } -/// Cheap to clone: shares one model instance across the app via an inner `Arc`. +/// 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>, @@ -43,6 +89,7 @@ impl Default for CommitAiEngine { Self { inner: Arc::new(RwLock::new(Inner { phase: CommitAiPhase::Idle, + model_id: None, error: None, model: None, })), @@ -59,30 +106,52 @@ impl CommitAiEngine { 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 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) { + /// 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 mut guard = self.inner.write().await; - if guard.phase != CommitAiPhase::Idle { + 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; } - guard.phase = CommitAiPhase::Loading; - guard.error = None; } - let result = GgufModelBuilder::new(HF_REPO, vec![GGUF_FILE]) - .with_tok_model_id(TOKENIZER_MODEL_ID) + 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!("Unbekanntes lokales Modell: {model_id}")); + 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; + } + + 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)); @@ -105,17 +174,11 @@ impl CommitAiEngine { 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()), + _ => return Err("Das lokale 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 (system, user) = build_messages(diff, notes)?; let messages = TextMessages::new() .add_message(TextMessageRole::System, system) .add_message(TextMessageRole::User, user); @@ -131,23 +194,60 @@ impl CommitAiEngine { .and_then(|choice| choice.message.content.clone()) .ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string())?; - Ok(content.trim().to_string()) + let message = sanitize_message(&content); + if message.is_empty() { + return Err("Das Modell hat keine Antwort geliefert.".to_string()); + } + Ok(message) } } -fn build_messages(diff: &str, notes: Option<&str>) -> (String, String) { +/// 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() +} + +pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> { + if diff.trim().is_empty() { + return Err("Keine gestagten Änderungen für eine Commit-Message vorhanden.".to_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]) + // Byte-Index auf eine gültige UTF-8-Zeichengrenze zurückziehen, sonst + // paniken Slices mitten in einem Umlaut o. Ä. + let mut cut = MAX_CHARS; + while !diff.is_char_boundary(cut) { + cut -= 1; + } + format!("{}\n\n[... Diff gekürzt ...]", &diff[..cut]) } 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 \ -((): ), optional gefolgt von einem Body nach einer Leerzeile. \ -Subject imperativ, max. 72 Zeichen. Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten" +((): ), gefolgt von einem Body nach einer Leerzeile. \ +Subject imperativ, max. 72 Zeichen. \ +Der Body ist Pflicht: Fasse in einem kurzen Absatz zusammen, was und warum geändert wurde, \ +und liste danach die wesentlichen Änderungen als Stichpunkte (- ...) auf, \ +gruppiert nach betroffenem Bereich/Datei. Zeilen im Body max. 72 Zeichen. \ +Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten" .to_string(); let mut user = String::new(); @@ -155,5 +255,5 @@ Subject imperativ, max. 72 Zeichen. Kein Vorspann, keine Erklärung, keine Code- user.push_str(&format!("Anmerkungen des Entwicklers:\n{n}\n\n")); } user.push_str(&format!("Staged diff:\n{diff}")); - (system, user) + Ok((system, user)) } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index b090d1d..bab89be 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -479,6 +479,11 @@ pub fn get_file_patch(path: String, file: String, staged: bool) -> Result Vec { + 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, - engine: tauri::State<'_, commit_ai::CommitAiEngine>, -) -> Result { - 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 { + // 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, + provider: String, + model: Option, + api_key: Option, + base_url: Option, + engine: tauri::State<'_, commit_ai::CommitAiEngine>, +) -> Result { + 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] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8fd5f5c..1422785 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index b7b89eb..e96df25 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,6 +5,7 @@ import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; import TitleBar from "./lib/TitleBar.svelte"; + import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte"; import CompareDialog from "./lib/components/CompareDialog.svelte"; @@ -26,6 +27,8 @@ checkoutBranch, commit, commitAiGenerate, + commitAiLoad, + commitAiLocalModels, commitAiStatus, compareCommits, cancelCodeSearch, @@ -64,6 +67,7 @@ } from "./lib/git"; import type { + AiSettings, CommitAiPhase, ConflictFile, ExplorerNode, @@ -77,6 +81,7 @@ GitRepositoryFile, GitSearchHit, GitStatus, + LocalModelOption, PatchApplyAction, PreparedResolution, StoredCredential, @@ -104,6 +109,7 @@ const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; + const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; // ── State ────────────────────────────────────────────────────────────────── @@ -130,6 +136,9 @@ let commitAiPhase: CommitAiPhase = "idle"; let commitAiGenerating = false; let commitAiPollTimer: ReturnType | undefined; + let aiSettings: AiSettings = defaultAiSettings(); + let aiSettingsOpen = false; + let localModelOptions: LocalModelOption[] = []; let errorMessage = ""; let operation = ""; let compareFrom = ""; @@ -213,7 +222,7 @@ loadRepoLists(); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); void checkForUpdates(); - startCommitAiPolling(); + void initCommitAi(); }); onDestroy(() => { @@ -256,29 +265,85 @@ // ── Commit AI ────────────────────────────────────────────────────────────── + function stopCommitAiPolling() { + if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; } + } + async function pollCommitAiStatus() { try { const result = await commitAiStatus(); commitAiPhase = result.phase; } catch { /* ignore transient errors */ } - if (commitAiPhase === "ready" || commitAiPhase === "error") { - if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; } - } + if (commitAiPhase === "ready" || commitAiPhase === "error") stopCommitAiPolling(); } function startCommitAiPolling() { - // The model downloads (first run only) and loads in the background on app start; - // poll until it's ready (or failed) so the "AI" button can enable itself. + // Only the local model has a download/load phase worth polling — cloud providers are + // plain API calls with nothing to wait for. + stopCommitAiPolling(); + if (aiSettings.provider !== "local") return; void pollCommitAiStatus(); commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000); } + async function initCommitAi() { + aiSettings = loadAiSettings(); + try { + localModelOptions = await commitAiLocalModels(); + } catch { /* AI features stay disabled if this fails; not fatal to the app */ } + if (aiSettings.provider === "local") { + try { await commitAiLoad(aiSettings.localModelId); } catch { /* surfaced via status polling */ } + } + startCommitAiPolling(); + } + + function saveAiSettings(next: AiSettings) { + const modelChanged = next.provider === "local" && next.localModelId !== aiSettings.localModelId; + aiSettings = next; + persistAiSettings(next); + aiSettingsOpen = false; + if (next.provider === "local" && (modelChanged || commitAiPhase === "idle")) { + commitAiPhase = "idle"; + void commitAiLoad(next.localModelId); + } + startCommitAiPolling(); + } + async function generateCommitMessageWithAi() { - if (!activeRepoPath || commitAiPhase !== "ready" || commitAiGenerating) return; + if (!activeRepoPath || commitAiGenerating) return; + if (aiSettings.provider === "local" && commitAiPhase !== "ready") return; commitAiGenerating = true; errorMessage = ""; try { - commitMessage = await commitAiGenerate(activeRepoPath, commitMessage.trim() || undefined); + const notes = commitMessage.trim() || undefined; + if (aiSettings.provider === "local") { + commitMessage = await commitAiGenerate(activeRepoPath, { provider: "local", notes }); + } else if (aiSettings.provider === "openai") { + const cred = await credLoad("ai:openai"); + commitMessage = await commitAiGenerate(activeRepoPath, { + provider: "openai", + notes, + model: aiSettings.openaiModel, + apiKey: cred?.password, + }); + } else if (aiSettings.provider === "anthropic") { + const cred = await credLoad("ai:anthropic"); + commitMessage = await commitAiGenerate(activeRepoPath, { + provider: "anthropic", + notes, + model: aiSettings.anthropicModel, + apiKey: cred?.password, + }); + } else { + const cred = await credLoad("ai:custom"); + commitMessage = await commitAiGenerate(activeRepoPath, { + provider: "custom", + notes, + model: aiSettings.customModel, + baseUrl: aiSettings.customBaseUrl, + apiKey: cred?.password, + }); + } } catch (error) { errorMessage = errorToMessage(error); } finally { @@ -446,6 +511,37 @@ } } + function defaultAiSettings(): AiSettings { + return { + provider: "local", + localModelId: "qwen2.5-1.5b", + openaiModel: "gpt-4o-mini", + anthropicModel: "claude-3-5-haiku-latest", + customBaseUrl: "", + customModel: "", + }; + } + + function loadAiSettings(): AiSettings { + try { + const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown; + if (stored && typeof stored === "object") { + return { ...defaultAiSettings(), ...(stored as Partial) }; + } + } catch { + // Fall through to defaults below. + } + return defaultAiSettings(); + } + + function persistAiSettings(next: AiSettings) { + try { + localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(next)); + } catch { + // Local storage is best-effort only; AI generation must keep working without it. + } + } + function rememberRecentRepo(path: string) { recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40); persistRepoLists(); @@ -1742,11 +1838,13 @@ {isBusy} {operation} {stagedCount} + commitAiProvider={aiSettings.provider} {commitAiPhase} {commitAiGenerating} onCommit={commitChanges} onCommitMessageChange={(msg) => { commitMessage = msg; }} onGenerateCommitMessage={generateCommitMessageWithAi} + onOpenAiSettings={() => { aiSettingsOpen = true; }} /> @@ -1850,6 +1948,16 @@ /> {/if} + +{#if aiSettingsOpen} + { aiSettingsOpen = false; }} + /> +{/if} + {#if compareSelectOpen} + import { onMount } from "svelte"; + import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte"; + import { credDelete, credLoad, credSave } from "../git"; + import type { AiSettings, CommitAiProvider, LocalModelOption } from "../types"; + + interface Props { + settings: AiSettings; + localModels: LocalModelOption[]; + onSave: (settings: AiSettings) => void; + onClose: () => void; + } + + let { settings, localModels = [], onSave, onClose }: Props = $props(); + + type CloudProvider = Exclude; + + const CRED_KEYS: Record = { + openai: "ai:openai", + anthropic: "ai:anthropic", + custom: "ai:custom", + }; + + let provider = $state("local"); + let localModelId = $state(""); + let openaiModel = $state(""); + let anthropicModel = $state(""); + let customBaseUrl = $state(""); + let customModel = $state(""); + + let openaiApiKey = $state(""); + let anthropicApiKey = $state(""); + let customApiKey = $state(""); + let showKey = $state(false); + let loadingKeys = $state(true); + let saving = $state(false); + let error = $state(""); + + $effect(() => { + provider = settings.provider; + localModelId = settings.localModelId; + openaiModel = settings.openaiModel; + anthropicModel = settings.anthropicModel; + customBaseUrl = settings.customBaseUrl; + customModel = settings.customModel; + }); + + onMount(() => { + (async () => { + try { + const [openai, anthropic, custom] = await Promise.all([ + credLoad(CRED_KEYS.openai), + credLoad(CRED_KEYS.anthropic), + credLoad(CRED_KEYS.custom), + ]); + openaiApiKey = openai?.password ?? ""; + anthropicApiKey = anthropic?.password ?? ""; + customApiKey = custom?.password ?? ""; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } finally { + loadingKeys = false; + } + })(); + }); + + async function persistKey(target: CloudProvider, value: string) { + const key = CRED_KEYS[target]; + const trimmed = value.trim(); + if (trimmed) { + await credSave(key, "api-key", trimmed, null); + } else { + await credDelete(key); + } + } + + async function handleSave() { + saving = true; + error = ""; + try { + await Promise.all([ + persistKey("openai", openaiApiKey), + persistKey("anthropic", anthropicApiKey), + persistKey("custom", customApiKey), + ]); + onSave({ + provider, + localModelId, + openaiModel: openaiModel.trim() || "gpt-4o-mini", + anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest", + customBaseUrl: customBaseUrl.trim(), + customModel: customModel.trim(), + }); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } finally { + saving = false; + } + } + + function formatSize(mb: number): string { + return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`; + } + + let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId)); + + + diff --git a/src/lib/components/CommitPanel.svelte b/src/lib/components/CommitPanel.svelte index c813f20..7f60c53 100644 --- a/src/lib/components/CommitPanel.svelte +++ b/src/lib/components/CommitPanel.svelte @@ -1,6 +1,6 @@ @@ -82,15 +91,25 @@ type="button" onclick={onGenerateCommitMessage} disabled={!canGenerate} - title={aiButtonTitle(commitAiPhase, stagedCount)} + title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)} > - {#if commitAiGenerating || commitAiPhase === "loading"} + {#if commitAiGenerating || localModelLoading}