feat(ui): localize commit AI and git error messages to English

Translate commit AI prompts, model labels, and git/keychain errors to
English so the app and generated messages are consistent. Also add a
discard confirmation dialog and update the UI text/styles to match the
new flow.

- src-tauri/crates/commit_ai/src/cloud.rs
  - Translate HTTP and API error messages to English.
  - Keep request timeout and token sizing behavior unchanged.
- src-tauri/crates/commit_ai/src/lib.rs
  - Translate model labels, prompt text, and validation errors.
  - Keep diff truncation and message sanitization logic intact.
- src-tauri/src/git.rs
  - Translate git, credential, merge, and history errors.
  - Update AI provider validation messages to English.
- src/lib/components/*
  - Update AI settings, commit panel, credential, and loading UI text.
  - Add discard confirmation dialog for destructive actions.
- src/App.svelte, src/app.css
  - Adjust app layout and styling for the new dialog and text changes.
This commit is contained in:
Christoph Brandau
2026-07-02 22:12:20 +02:00
parent 6b7186d040
commit 70de45e1ba
11 changed files with 537 additions and 187 deletions
+16 -16
View File
@@ -4,16 +4,16 @@ use serde::{Deserialize, Serialize};
use crate::{build_messages, sanitize_message};
// Großzügig bemessen, damit ein ausführlicher Body mit Stichpunkten nicht abgeschnitten wird.
// Generous sizing so a detailed body with bullet points isn't cut off.
const DEFAULT_MAX_TOKENS: u32 = 1500;
// Ohne Timeout würde ein hängender Endpoint den "AI"-Button dauerhaft blockieren.
// Without a timeout, a hanging endpoint would permanently block the "AI" button.
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}"))
.map_err(|err| format!("Could not create HTTP client: {err}"))
}
#[derive(Serialize)]
@@ -71,19 +71,19 @@ async fn openai_compatible_request(
let response = request
.send()
.await
.map_err(|err| format!("Anfrage an das KI-Modell fehlgeschlagen: {err}"))?;
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Antwort konnte nicht gelesen werden: {err}"))?;
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API-Fehler ({status}): {text}"));
return Err(format!("API error ({status}): {text}"));
}
let parsed: OpenAiResponse = serde_json::from_str(&text)
.map_err(|err| format!("Antwort konnte nicht verarbeitet werden: {err}"))?;
.map_err(|err| format!("Could not process response: {err}"))?;
parsed
.choices
@@ -92,7 +92,7 @@ async fn openai_compatible_request(
.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())
.ok_or_else(|| "The model did not return a response.".to_string())
}
pub async fn generate_openai(
@@ -102,7 +102,7 @@ pub async fn generate_openai(
notes: Option<&str>,
) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("OpenAI-API-Key fehlt.".to_string());
return Err("OpenAI API key is missing.".to_string());
}
openai_compatible_request(
"https://api.openai.com/v1/chat/completions".to_string(),
@@ -122,7 +122,7 @@ pub async fn generate_custom(
notes: Option<&str>,
) -> Result<String, String> {
if base_url.trim().is_empty() {
return Err("Endpoint-URL fehlt.".to_string());
return Err("Endpoint URL is missing.".to_string());
}
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
openai_compatible_request(url, api_key, model, diff, notes).await
@@ -161,7 +161,7 @@ pub async fn generate_anthropic(
notes: Option<&str>,
) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("Anthropic-API-Key fehlt.".to_string());
return Err("Anthropic API key is missing.".to_string());
}
let (system, user) = build_messages(diff, notes)?;
let body = AnthropicRequest {
@@ -179,19 +179,19 @@ pub async fn generate_anthropic(
.json(&body)
.send()
.await
.map_err(|err| format!("Anfrage an Anthropic fehlgeschlagen: {err}"))?;
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Antwort konnte nicht gelesen werden: {err}"))?;
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API-Fehler ({status}): {text}"));
return Err(format!("API error ({status}): {text}"));
}
let parsed: AnthropicResponse = serde_json::from_str(&text)
.map_err(|err| format!("Antwort konnte nicht verarbeitet werden: {err}"))?;
.map_err(|err| format!("Could not process response: {err}"))?;
parsed
.content
@@ -199,5 +199,5 @@ pub async fn generate_anthropic(
.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())
.ok_or_else(|| "The model did not return a response.".to_string())
}
+21 -21
View File
@@ -24,7 +24,7 @@ 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",
label: "Qwen2.5 0.5B Instruct — fast, lower quality",
approx_size_mb: 490,
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
@@ -32,7 +32,7 @@ pub const LOCAL_MODELS: &[LocalModelOption] = &[
},
LocalModelOption {
id: "qwen2.5-1.5b",
label: "Qwen2.5 1.5B Instruct — empfohlen",
label: "Qwen2.5 1.5B Instruct — recommended",
approx_size_mb: 1050,
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
@@ -40,7 +40,7 @@ pub const LOCAL_MODELS: &[LocalModelOption] = &[
},
LocalModelOption {
id: "qwen2.5-3b",
label: "Qwen2.5 3B Instruct — beste Qualität, langsamer",
label: "Qwen2.5 3B Instruct — best quality, slower",
approx_size_mb: 2100,
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
file: "qwen2.5-3b-instruct-q4_k_m.gguf",
@@ -128,7 +128,7 @@ impl CommitAiEngine {
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}"));
guard.error = Some(format!("Unknown local model: {model_id}"));
return;
};
@@ -174,7 +174,7 @@ impl CommitAiEngine {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(),
_ => return Err("Das lokale KI-Modell ist noch nicht bereit.".to_string()),
_ => return Err("The local AI model is not ready yet.".to_string()),
}
};
@@ -192,11 +192,11 @@ impl CommitAiEngine {
.choices
.first()
.and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string())?;
.ok_or_else(|| "The model did not return a response.".to_string())?;
let message = sanitize_message(&content);
if message.is_empty() {
return Err("Das Modell hat keine Antwort geliefert.".to_string());
return Err("The model did not return a response.".to_string());
}
Ok(message)
}
@@ -223,36 +223,36 @@ pub(crate) fn sanitize_message(raw: &str) -> 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());
return Err("No staged changes available for a commit message.".to_string());
}
// grobe Token-Schätzung, kleine Modelle haben oft 832k Kontext
// Rough token estimate — small models often have an 8-32k context window.
const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS {
// Byte-Index auf eine gültige UTF-8-Zeichengrenze zurückziehen, sonst
// paniken Slices mitten in einem Umlaut o. Ä.
// Pull the byte index back to a valid UTF-8 char boundary, otherwise
// slicing mid-multi-byte-character would panic.
let mut cut = MAX_CHARS;
while !diff.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... Diff gekürzt ...]", &diff[..cut])
format!("{}\n\n[... diff truncated ...]", &diff[..cut])
} else {
diff.to_string()
};
let system = "Du bist ein Werkzeug, das Git-Commit-Messages erzeugt. \
Antworte ausschließlich mit der Commit-Message im Conventional-Commits-Format \
(<type>(<scope>): <subject>), gefolgt von einem Body nach einer Leerzeile. \
Subject imperativ, max. 72 Zeichen. \
Der Body ist Pflicht: Fasse in einem kurzen Absatz zusammen, was und warum geändert wurde, \
und liste danach die wesentlichen Änderungen als Stichpunkte (- ...) auf, \
gruppiert nach betroffenem Bereich/Datei. Zeilen im Body max. 72 Zeichen. \
Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten"
let system = "You are a tool that generates Git commit messages. \
Respond only with the commit message in Conventional Commits format \
(<type>(<scope>): <subject>), followed by a body after a blank line. \
Subject in imperative mood, max. 72 characters. \
The body is required: summarize in a short paragraph what changed and why, \
then list the key changes as bullet points (- ...), \
grouped by affected area/file. Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English"
.to_string();
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Anmerkungen des Entwicklers:\n{n}\n\n"));
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged diff:\n{diff}"));
Ok((system, user))