Features/ai commits #8

Merged
Christoph merged 4 commits from features/ai_commits into master 2026-07-02 20:15:00 +00:00
13 changed files with 924 additions and 80 deletions
Showing only changes of commit 6b7186d040 - Show all commits
+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;