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:
Christoph Brandau
2026-07-02 21:19:16 +02:00
parent d415cbd3a1
commit 6b7186d040
13 changed files with 924 additions and 80 deletions
+11 -1
View File
@@ -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 -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(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(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)"
] ]
} }
} }
+2
View File
@@ -1028,7 +1028,9 @@ name = "commit_ai"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"mistralrs", "mistralrs",
"reqwest 0.12.28",
"serde", "serde",
"serde_json",
"tokio", "tokio",
] ]
+2
View File
@@ -8,3 +8,5 @@ edition = "2024"
mistralrs = "0.8" mistralrs = "0.8"
tokio = { version = "1", features = ["sync"] } tokio = { version = "1", features = ["sync"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+203
View File
@@ -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, String> {
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<OpenAiMessage>,
temperature: f32,
}
#[derive(Deserialize)]
struct OpenAiResponseMessage {
content: Option<String>,
}
#[derive(Deserialize)]
struct OpenAiChoice {
message: OpenAiResponseMessage,
}
#[derive(Deserialize)]
struct OpenAiResponse {
#[serde(default)]
choices: Vec<OpenAiChoice>,
}
async fn openai_compatible_request(
url: String,
bearer: Option<&str>,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<AnthropicMessage>,
}
#[derive(Deserialize)]
struct AnthropicContentBlock {
#[serde(default)]
text: Option<String>,
}
#[derive(Deserialize)]
struct AnthropicResponse {
#[serde(default)]
content: Vec<AnthropicContentBlock>,
}
pub async fn generate_anthropic(
api_key: &str,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
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())
}
+130 -30
View File
@@ -1,13 +1,56 @@
mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
use std::sync::Arc; use std::sync::Arc;
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages}; use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
use tokio::sync::RwLock; use tokio::sync::RwLock;
// Small instruct model, quantized to keep the one-time download reasonable (~1 GB) while /// One selectable local (on-device) model. Larger models produce better commit messages
// still being fast enough for short, structured generations like a commit message on CPU. /// but take longer to download (first run only, then cached) and run slower on CPU.
const HF_REPO: &str = "Qwen/Qwen2.5-1.5B-Instruct-GGUF"; #[derive(Debug, Clone, serde::Serialize)]
const GGUF_FILE: &str = "qwen2.5-1.5b-instruct-q4_k_m.gguf"; pub struct LocalModelOption {
const TOKENIZER_MODEL_ID: &str = "Qwen/Qwen2.5-1.5B-Instruct"; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -23,16 +66,19 @@ pub enum CommitAiPhase {
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
pub struct CommitAiStatus { pub struct CommitAiStatus {
pub phase: CommitAiPhase, pub phase: CommitAiPhase,
pub model_id: Option<String>,
pub error: Option<String>, pub error: Option<String>,
} }
struct Inner { struct Inner {
phase: CommitAiPhase, phase: CommitAiPhase,
model_id: Option<String>,
error: Option<String>, error: Option<String>,
model: Option<Arc<Model>>, model: Option<Arc<Model>>,
} }
/// 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)] #[derive(Clone)]
pub struct CommitAiEngine { pub struct CommitAiEngine {
inner: Arc<RwLock<Inner>>, inner: Arc<RwLock<Inner>>,
@@ -43,6 +89,7 @@ impl Default for CommitAiEngine {
Self { Self {
inner: Arc::new(RwLock::new(Inner { inner: Arc::new(RwLock::new(Inner {
phase: CommitAiPhase::Idle, phase: CommitAiPhase::Idle,
model_id: None,
error: None, error: None,
model: None, model: None,
})), })),
@@ -59,30 +106,52 @@ impl CommitAiEngine {
let guard = self.inner.read().await; let guard = self.inner.read().await;
CommitAiStatus { CommitAiStatus {
phase: guard.phase, phase: guard.phase,
model_id: guard.model_id.clone(),
error: guard.error.clone(), error: guard.error.clone(),
} }
} }
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the model. /// Downloads (first run only; hf-hub caches the files afterwards) and loads the given
/// Safe to call multiple times — only the first caller actually triggers a load, later /// local model. Safe to call repeatedly — a call for the model that's already
/// callers just return once the in-flight or previous attempt is done. /// ready/loading is a no-op; a call for a *different* model switches to it (the
pub async fn ensure_loaded(&self) { /// 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; let guard = self.inner.read().await;
if guard.phase != CommitAiPhase::Idle { let same_model = guard.model_id.as_deref() == Some(model_id);
if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) {
return; return;
} }
guard.phase = CommitAiPhase::Loading;
guard.error = None;
} }
let result = GgufModelBuilder::new(HF_REPO, vec![GGUF_FILE]) let Some(option) = find_local_model(model_id) else {
.with_tok_model_id(TOKENIZER_MODEL_ID) 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() .with_logging()
.build() .build()
.await; .await;
let mut guard = self.inner.write().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 { match result {
Ok(model) => { Ok(model) => {
guard.model = Some(Arc::new(model)); guard.model = Some(Arc::new(model));
@@ -105,17 +174,11 @@ impl CommitAiEngine {
let guard = self.inner.read().await; let guard = self.inner.read().await;
match (guard.phase, &guard.model) { match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(), (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() { let (system, user) = build_messages(diff, notes)?;
return Err(
"Keine gestagten Änderungen für eine Commit-Message vorhanden.".to_string(),
);
}
let (system, user) = build_messages(diff, notes);
let messages = TextMessages::new() let messages = TextMessages::new()
.add_message(TextMessageRole::System, system) .add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user); .add_message(TextMessageRole::User, user);
@@ -131,23 +194,60 @@ impl CommitAiEngine {
.and_then(|choice| choice.message.content.clone()) .and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string())?; .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 832k Kontext // grobe Token-Schätzung, kleine Modelle haben oft 832k Kontext
const MAX_CHARS: usize = 24_000; const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS { 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 { } else {
diff.to_string() diff.to_string()
}; };
let system = "Du bist ein Werkzeug, das Git-Commit-Messages erzeugt. \ let system = "Du bist ein Werkzeug, das Git-Commit-Messages erzeugt. \
Antworte ausschließlich mit der Commit-Message im Conventional-Commits-Format \ Antworte ausschließlich mit der Commit-Message im Conventional-Commits-Format \
(<type>(<scope>): <subject>), optional gefolgt von einem Body nach einer Leerzeile. \ (<type>(<scope>): <subject>), gefolgt von einem Body nach einer Leerzeile. \
Subject imperativ, max. 72 Zeichen. Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten" 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(); .to_string();
let mut user = String::new(); 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!("Anmerkungen des Entwicklers:\n{n}\n\n"));
} }
user.push_str(&format!("Staged diff:\n{diff}")); user.push_str(&format!("Staged diff:\n{diff}"));
(system, user) Ok((system, user))
} }
+79 -11
View File
@@ -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()) 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] #[tauri::command]
pub async fn commit_ai_status( pub async fn commit_ai_status(
engine: tauri::State<'_, commit_ai::CommitAiEngine>, engine: tauri::State<'_, commit_ai::CommitAiEngine>,
@@ -486,28 +491,91 @@ pub async fn commit_ai_status(
Ok(engine.status().await) 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] #[tauri::command]
pub async fn commit_ai_generate( pub fn commit_ai_load(model_id: String, engine: tauri::State<'_, commit_ai::CommitAiEngine>) {
path: String, let engine = engine.inner().clone();
notes: Option<String>, tauri::async_runtime::spawn(async move {
engine: tauri::State<'_, commit_ai::CommitAiEngine>, engine.ensure_loaded(&model_id).await;
) -> Result<String, String> { });
let repo = resolve_repo(&path)?; }
// 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( let diff = run_git(
&repo, repo,
[ [
"diff", "diff",
"--cached", "--cached",
"--no-ext-diff", "--no-ext-diff",
"--no-textconv", "--no-textconv",
"--unified=3", "--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 if file_list.is_empty() {
.generate_commit_message(&diff, notes.as_deref()) return Ok(diff.to_string());
.await }
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] #[tauri::command]
+11 -20
View File
@@ -4,33 +4,22 @@ mod git;
use git::{ use git::{
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history, SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
checkout_branch, commit, commit_ai_generate, commit_ai_status, compare_commits, checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent,
delete_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, create_branch, cred_delete, cred_load, cred_save, delete_branch,
list_branches, list_commits, list_file_history, list_repository_files, merge_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
push, read_conflict, rename_branch, resolve_conflict, resolve_conflict_side, open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict,
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
stage_files, unstage_files, restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
}; };
fn main() { fn main() {
let commit_ai_engine = commit_ai::CommitAiEngine::new();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.manage(SearchCancellationState::default()) .manage(SearchCancellationState::default())
.manage(commit_ai_engine.clone()) .manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init()) .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![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
open_repo_in_explorer, open_repo_in_explorer,
@@ -48,6 +37,8 @@ fn main() {
apply_file_patch, apply_file_patch,
commit, commit,
commit_ai_status, commit_ai_status,
commit_ai_load,
commit_ai_local_models,
commit_ai_generate, commit_ai_generate,
pull, pull,
push, push,
+116 -8
View File
@@ -5,6 +5,7 @@
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte"; import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte"; import CompareDialog from "./lib/components/CompareDialog.svelte";
@@ -26,6 +27,8 @@
checkoutBranch, checkoutBranch,
commit, commit,
commitAiGenerate, commitAiGenerate,
commitAiLoad,
commitAiLocalModels,
commitAiStatus, commitAiStatus,
compareCommits, compareCommits,
cancelCodeSearch, cancelCodeSearch,
@@ -64,6 +67,7 @@
} from "./lib/git"; } from "./lib/git";
import type { import type {
AiSettings,
CommitAiPhase, CommitAiPhase,
ConflictFile, ConflictFile,
ExplorerNode, ExplorerNode,
@@ -77,6 +81,7 @@
GitRepositoryFile, GitRepositoryFile,
GitSearchHit, GitSearchHit,
GitStatus, GitStatus,
LocalModelOption,
PatchApplyAction, PatchApplyAction,
PreparedResolution, PreparedResolution,
StoredCredential, StoredCredential,
@@ -104,6 +109,7 @@
const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
@@ -130,6 +136,9 @@
let commitAiPhase: CommitAiPhase = "idle"; let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false; let commitAiGenerating = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined; let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
let aiSettings: AiSettings = defaultAiSettings();
let aiSettingsOpen = false;
let localModelOptions: LocalModelOption[] = [];
let errorMessage = ""; let errorMessage = "";
let operation = ""; let operation = "";
let compareFrom = ""; let compareFrom = "";
@@ -213,7 +222,7 @@
loadRepoLists(); loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
void checkForUpdates(); void checkForUpdates();
startCommitAiPolling(); void initCommitAi();
}); });
onDestroy(() => { onDestroy(() => {
@@ -256,29 +265,85 @@
// ── Commit AI ────────────────────────────────────────────────────────────── // ── Commit AI ──────────────────────────────────────────────────────────────
function stopCommitAiPolling() {
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
}
async function pollCommitAiStatus() { async function pollCommitAiStatus() {
try { try {
const result = await commitAiStatus(); const result = await commitAiStatus();
commitAiPhase = result.phase; commitAiPhase = result.phase;
} catch { /* ignore transient errors */ } } catch { /* ignore transient errors */ }
if (commitAiPhase === "ready" || commitAiPhase === "error") { if (commitAiPhase === "ready" || commitAiPhase === "error") stopCommitAiPolling();
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
}
} }
function startCommitAiPolling() { function startCommitAiPolling() {
// The model downloads (first run only) and loads in the background on app start; // Only the local model has a download/load phase worth polling — cloud providers are
// poll until it's ready (or failed) so the "AI" button can enable itself. // plain API calls with nothing to wait for.
stopCommitAiPolling();
if (aiSettings.provider !== "local") return;
void pollCommitAiStatus(); void pollCommitAiStatus();
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000); 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() { async function generateCommitMessageWithAi() {
if (!activeRepoPath || commitAiPhase !== "ready" || commitAiGenerating) return; if (!activeRepoPath || commitAiGenerating) return;
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
commitAiGenerating = true; commitAiGenerating = true;
errorMessage = ""; errorMessage = "";
try { 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) { } catch (error) {
errorMessage = errorToMessage(error); errorMessage = errorToMessage(error);
} finally { } 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<AiSettings>) };
}
} 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) { function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40); recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists(); persistRepoLists();
@@ -1742,11 +1838,13 @@
{isBusy} {isBusy}
{operation} {operation}
{stagedCount} {stagedCount}
commitAiProvider={aiSettings.provider}
{commitAiPhase} {commitAiPhase}
{commitAiGenerating} {commitAiGenerating}
onCommit={commitChanges} onCommit={commitChanges}
onCommitMessageChange={(msg) => { commitMessage = msg; }} onCommitMessageChange={(msg) => { commitMessage = msg; }}
onGenerateCommitMessage={generateCommitMessageWithAi} onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }}
/> />
</div> </div>
</section> </section>
@@ -1850,6 +1948,16 @@
/> />
{/if} {/if}
<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
<AiSettingsDialog
settings={aiSettings}
localModels={localModelOptions}
onSave={saveAiSettings}
onClose={() => { aiSettingsOpen = false; }}
/>
{/if}
<!-- Compare: pick the two commits to diff --> <!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen} {#if compareSelectOpen}
<CompareSelectDialog <CompareSelectDialog
+43
View File
@@ -1334,6 +1334,7 @@
.commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; } .commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; }
.commit-actions-row { display: flex; gap: 8px; } .commit-actions-row { display: flex; gap: 8px; }
.commit-ai-button { flex: 0 0 auto; min-width: 64px; justify-content: center; } .commit-ai-button { flex: 0 0 auto; min-width: 64px; justify-content: center; }
.commit-ai-settings-button { flex: 0 0 auto; width: 38px; min-width: 38px; padding: 0; justify-content: center; }
.commit-block-reason { .commit-block-reason {
margin: 0; margin: 0;
padding: 8px 10px; padding: 8px 10px;
@@ -1605,6 +1606,48 @@
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
overflow: auto; overflow: auto;
} }
.ai-settings-dialog {
display: block;
width: min(560px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.ai-settings-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.ai-provider-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.ai-provider-option {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 38px;
padding: 0 10px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: rgba(255,255,255,0.03);
color: var(--color-ink-dim);
font-size: 12.5px;
font-weight: 700;
}
.ai-provider-option:hover {
border-color: var(--color-border);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.ai-provider-option.active {
border-color: rgba(100,108,255,0.5);
color: #f5f7ff;
background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1));
}
.new-branch-form { .new-branch-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+256
View File
@@ -0,0 +1,256 @@
<script lang="ts">
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<CommitAiProvider, "local">;
const CRED_KEYS: Record<CloudProvider, string> = {
openai: "ai:openai",
anthropic: "ai:anthropic",
custom: "ai:custom",
};
let provider = $state<CommitAiProvider>("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));
</script>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Commit AI</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">AI settings</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
<button type="button" class="ai-provider-option" class:active={provider === "local"} onclick={() => { provider = "local"; }}>
<Cpu size={16} aria-hidden="true" />
Local AI
</button>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" />
OpenAI
</button>
<button type="button" class="ai-provider-option" class:active={provider === "anthropic"} onclick={() => { provider = "anthropic"; }}>
<Bot size={16} aria-hidden="true" />
Anthropic (Claude)
</button>
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
<Globe size={16} aria-hidden="true" />
Custom endpoint
</button>
</div>
{#if provider === "local"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<select bind:value={localModelId}>
{#each localModels as option (option.id)}
<option value={option.id}>{option.label} {formatSize(option.approx_size_mb)}</option>
{/each}
</select>
</label>
<div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" />
<span>
Beim Wechsel wird das Modell{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
im Hintergrund heruntergeladen — je nach Internetverbindung kann das mehrere Minuten dauern.
Danach bleibt es lokal zwischengespeichert und lädt beim nächsten Start sofort.
</span>
</div>
{:else if provider === "openai"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={openaiApiKey}
placeholder="sk-..."
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{:else if provider === "anthropic"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={anthropicApiKey}
placeholder="sk-ant-..."
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{:else}
<label class="cred-field">
<span class="cred-field-label">Endpoint URL</span>
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
</label>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key (optional)</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={customApiKey}
placeholder="Optional"
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
<div class="cred-token-hint">
<Globe size={13} aria-hidden="true" />
<span>Für lokale OpenAI-kompatible Server wie Ollama oder LM Studio. Die Basis-URL sollte auf /v1 enden.</span>
</div>
{/if}
{#if error}
<p class="commit-block-reason">{error}</p>
{/if}
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={saving}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={saving || loadingKeys}>
{#if saving}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Save
</button>
</div>
</form>
</div>
</div>
+27 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check, LoaderCircle, Sparkles } from "@lucide/svelte"; import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase } from "../types"; import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props { interface Props {
commitMessage: string; commitMessage: string;
@@ -10,11 +10,13 @@
isBusy: boolean; isBusy: boolean;
operation: string; operation: string;
stagedCount: number; stagedCount: number;
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase; commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean; commitAiGenerating: boolean;
onCommit: () => void; onCommit: () => void;
onCommitMessageChange: (msg: string) => void; onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void; onGenerateCommitMessage: () => void;
onOpenAiSettings: () => void;
} }
let { let {
@@ -25,11 +27,13 @@
isBusy = false, isBusy = false,
operation = "", operation = "",
stagedCount = 0, stagedCount = 0,
commitAiProvider = "local",
commitAiPhase = "idle", commitAiPhase = "idle",
commitAiGenerating = false, commitAiGenerating = false,
onCommit = () => {}, onCommit = () => {},
onCommitMessageChange = () => {}, onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {}, onGenerateCommitMessage = () => {},
onOpenAiSettings = () => {},
}: Props = $props(); }: Props = $props();
function handleSubmit(event: SubmitEvent) { function handleSubmit(event: SubmitEvent) {
@@ -37,15 +41,20 @@
onCommit(); onCommit();
} }
function aiButtonTitle(phase: CommitAiPhase, staged: number): string { function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
if (phase === "loading") return "AI model is downloading/loading — this happens once";
if (phase === "error") return "AI model failed to load";
if (staged === 0) return "Stage changes first"; if (staged === 0) return "Stage changes first";
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
return "Generate commit message with AI from the staged diff"; return "Generate commit message with AI from the staged diff";
} }
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
let canGenerate = $derived( let canGenerate = $derived(
hasRepository && !isBusy && !commitAiGenerating && commitAiPhase === "ready" && stagedCount > 0, hasRepository &&
!isBusy &&
!commitAiGenerating &&
stagedCount > 0 &&
(commitAiProvider !== "local" || commitAiPhase === "ready"),
); );
</script> </script>
@@ -82,15 +91,25 @@
type="button" type="button"
onclick={onGenerateCommitMessage} onclick={onGenerateCommitMessage}
disabled={!canGenerate} disabled={!canGenerate}
title={aiButtonTitle(commitAiPhase, stagedCount)} title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
> >
{#if commitAiGenerating || commitAiPhase === "loading"} {#if commitAiGenerating || localModelLoading}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else} {:else}
<Sparkles size={16} aria-hidden="true" /> <Sparkles size={16} aria-hidden="true" />
{/if} {/if}
AI AI
</button> </button>
<button
class="btn-secondary commit-ai-settings-button"
type="button"
onclick={onOpenAiSettings}
disabled={isBusy}
title="AI settings"
aria-label="AI settings"
>
<Settings size={16} aria-hidden="true" />
</button>
</div> </div>
</form> </form>
</section> </section>
+27 -2
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { import type {
CommitAiProvider,
CommitAiStatus, CommitAiStatus,
ConflictFile, ConflictFile,
GitBranch, GitBranch,
@@ -9,6 +10,7 @@ import type {
GitRepositoryFile, GitRepositoryFile,
GitSearchHit, GitSearchHit,
GitStatus, GitStatus,
LocalModelOption,
PatchApplyAction, PatchApplyAction,
RepositoryBundle, RepositoryBundle,
StoredCredential, StoredCredential,
@@ -99,8 +101,31 @@ export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status"); return invoke<CommitAiStatus>("commit_ai_status");
} }
export function commitAiGenerate(path: string, notes?: string): Promise<string> { export function commitAiLoad(modelId: string): Promise<void> {
return invoke<string>("commit_ai_generate", { path, notes }); return invoke<void>("commit_ai_load", { modelId });
}
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
return invoke<LocalModelOption[]>("commit_ai_local_models");
}
export interface CommitAiGenerateOptions {
notes?: string;
provider: CommitAiProvider;
model?: string;
apiKey?: string;
baseUrl?: string;
}
export function commitAiGenerate(path: string, options: CommitAiGenerateOptions): Promise<string> {
return invoke<string>("commit_ai_generate", {
path,
notes: options.notes,
provider: options.provider,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
} }
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> { export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
+17
View File
@@ -8,12 +8,29 @@ export type FileStatusKind =
| "unknown"; | "unknown";
export type CommitAiPhase = "idle" | "loading" | "ready" | "error"; export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export interface CommitAiStatus { export interface CommitAiStatus {
phase: CommitAiPhase; phase: CommitAiPhase;
model_id: string | null;
error: string | null; error: string | null;
} }
export interface LocalModelOption {
id: string;
label: string;
approx_size_mb: number;
}
export interface AiSettings {
provider: CommitAiProvider;
localModelId: string;
openaiModel: string;
anthropicModel: string;
customBaseUrl: string;
customModel: string;
}
export interface GitStatus { export interface GitStatus {
repo_path: string; repo_path: string;
current_branch: string | null; current_branch: string | null;