Features/ai commits #9

Merged
Christoph merged 2 commits from features/ai_commits into master 2026-07-03 05:27:15 +00:00
10 changed files with 441 additions and 69 deletions
+3 -1
View File
@@ -71,7 +71,9 @@
"Bash(echo \"EXIT:$?\")", "Bash(echo \"EXIT:$?\")",
"Bash(ls target/)", "Bash(ls target/)",
"Bash(rustup target *)", "Bash(rustup target *)",
"Bash(echo \"exit code: $?\")" "Bash(echo \"exit code: $?\")",
"Read(//home/cbr/.cargo/registry/src/**)",
"Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)"
] ]
} }
} }
+31 -12
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{build_messages, sanitize_message}; use crate::{build_messages, looks_like_diff_echo, sanitize_message};
// Generous sizing so a detailed body with bullet points isn't cut off. // Generous sizing so a detailed body with bullet points isn't cut off.
const DEFAULT_MAX_TOKENS: u32 = 1500; const DEFAULT_MAX_TOKENS: u32 = 1500;
@@ -56,8 +56,14 @@ async fn openai_compatible_request(
let body = OpenAiRequest { let body = OpenAiRequest {
model: model.to_string(), model: model.to_string(),
messages: vec![ messages: vec![
OpenAiMessage { role: "system", content: system }, OpenAiMessage {
OpenAiMessage { role: "user", content: user }, role: "system",
content: system,
},
OpenAiMessage {
role: "user",
content: user,
},
], ],
temperature: 0.3, temperature: 0.3,
}; };
@@ -82,17 +88,22 @@ async fn openai_compatible_request(
return Err(format!("API error ({status}): {text}")); return Err(format!("API error ({status}): {text}"));
} }
let parsed: OpenAiResponse = serde_json::from_str(&text) let parsed: OpenAiResponse =
.map_err(|err| format!("Could not process response: {err}"))?; serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed let message = parsed
.choices .choices
.into_iter() .into_iter()
.next() .next()
.and_then(|choice| choice.message.content) .and_then(|choice| choice.message.content)
.map(|content| sanitize_message(&content)) .map(|content| sanitize_message(&content))
.filter(|content| !content.is_empty()) .filter(|content| !content.is_empty())
.ok_or_else(|| "The model did not return a response.".to_string()) .ok_or_else(|| "The model did not return a response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
} }
pub async fn generate_openai( pub async fn generate_openai(
@@ -168,7 +179,10 @@ pub async fn generate_anthropic(
model: model.to_string(), model: model.to_string(),
max_tokens: DEFAULT_MAX_TOKENS, max_tokens: DEFAULT_MAX_TOKENS,
system, system,
messages: vec![AnthropicMessage { role: "user", content: user }], messages: vec![AnthropicMessage {
role: "user",
content: user,
}],
}; };
let client = http_client()?; let client = http_client()?;
@@ -190,14 +204,19 @@ pub async fn generate_anthropic(
return Err(format!("API error ({status}): {text}")); return Err(format!("API error ({status}): {text}"));
} }
let parsed: AnthropicResponse = serde_json::from_str(&text) let parsed: AnthropicResponse =
.map_err(|err| format!("Could not process response: {err}"))?; serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed let message = parsed
.content .content
.into_iter() .into_iter()
.find_map(|block| block.text) .find_map(|block| block.text)
.map(|text| sanitize_message(&text)) .map(|text| sanitize_message(&text))
.filter(|text| !text.is_empty()) .filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a response.".to_string()) .ok_or_else(|| "The model did not return a response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
} }
+213 -27
View File
@@ -2,9 +2,13 @@ mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai}; pub use cloud::{generate_anthropic, generate_custom, generate_openai};
use std::sync::Arc; use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages}; use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// One selectable local (on-device) model. Larger models produce better commit messages /// One selectable local (on-device) model. Larger models produce better commit messages
@@ -19,7 +23,7 @@ pub struct LocalModelOption {
tokenizer_repo: &'static str, tokenizer_repo: &'static str,
} }
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-1.5b"; pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[ pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption { LocalModelOption {
@@ -52,6 +56,59 @@ fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
LOCAL_MODELS.iter().find(|option| option.id == model_id) LOCAL_MODELS.iter().find(|option| option.id == model_id)
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocalGenerationProfile {
Fast,
Balanced,
Detailed,
}
impl Default for LocalGenerationProfile {
fn default() -> Self {
Self::Fast
}
}
impl LocalGenerationProfile {
pub fn from_id(value: Option<&str>) -> Self {
match value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"balanced" => Self::Balanced,
"detailed" => Self::Detailed,
_ => Self::Fast,
}
}
pub fn diff_unified_context(self) -> &'static str {
match self {
Self::Fast => "--unified=1",
Self::Balanced => "--unified=2",
Self::Detailed => "--unified=3",
}
}
fn max_diff_chars(self) -> usize {
match self {
Self::Fast => 8_000,
Self::Balanced => 12_000,
Self::Detailed => 24_000,
}
}
fn max_output_tokens(self) -> usize {
match self {
Self::Fast => 160,
Self::Balanced => 360,
Self::Detailed => 750,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum CommitAiPhase { pub enum CommitAiPhase {
@@ -75,6 +132,20 @@ struct Inner {
model_id: Option<String>, model_id: Option<String>,
error: Option<String>, error: Option<String>,
model: Option<Arc<Model>>, model: Option<Arc<Model>>,
cache: Option<GenerationCache>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GenerationCacheKey {
model_id: String,
profile: LocalGenerationProfile,
input_hash: u64,
}
#[derive(Debug, Clone)]
struct GenerationCache {
key: GenerationCacheKey,
message: String,
} }
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls /// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
@@ -92,6 +163,7 @@ impl Default for CommitAiEngine {
model_id: None, model_id: None,
error: None, error: None,
model: None, model: None,
cache: None,
})), })),
} }
} }
@@ -129,6 +201,7 @@ impl CommitAiEngine {
guard.phase = CommitAiPhase::Error; guard.phase = CommitAiPhase::Error;
guard.model_id = Some(model_id.to_string()); guard.model_id = Some(model_id.to_string());
guard.error = Some(format!("Unknown local model: {model_id}")); guard.error = Some(format!("Unknown local model: {model_id}"));
guard.cache = None;
return; return;
}; };
@@ -138,6 +211,7 @@ impl CommitAiEngine {
guard.model_id = Some(model_id.to_string()); guard.model_id = Some(model_id.to_string());
guard.error = None; guard.error = None;
guard.model = None; guard.model = None;
guard.cache = None;
} }
let result = GgufModelBuilder::new(option.repo, vec![option.file]) let result = GgufModelBuilder::new(option.repo, vec![option.file])
@@ -157,10 +231,12 @@ impl CommitAiEngine {
guard.model = Some(Arc::new(model)); guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready; guard.phase = CommitAiPhase::Ready;
guard.error = None; guard.error = None;
guard.cache = None;
} }
Err(err) => { Err(err) => {
guard.phase = CommitAiPhase::Error; guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string()); guard.error = Some(err.to_string());
guard.cache = None;
} }
} }
} }
@@ -169,22 +245,36 @@ impl CommitAiEngine {
&self, &self,
diff: &str, diff: &str,
notes: Option<&str>, notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<String, String> { ) -> Result<String, String> {
let model = { let (model, cache_key) = {
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)) => {
let cache_key = GenerationCacheKey {
model_id: guard.model_id.clone().unwrap_or_default(),
profile,
input_hash: generation_input_hash(diff, notes),
};
if let Some(cache) = &guard.cache {
if cache.key == cache_key {
return Ok(cache.message.clone());
}
}
(model.clone(), cache_key)
}
_ => return Err("The local AI model is not ready yet.".to_string()), _ => return Err("The local AI model is not ready yet.".to_string()),
} }
}; };
let (system, user) = build_messages(diff, notes)?; let (system, user) = build_local_messages(diff, notes, profile)?;
let messages = TextMessages::new() let request = RequestBuilder::new()
.set_sampler_max_len(profile.max_output_tokens())
.add_message(TextMessageRole::System, system) .add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user); .add_message(TextMessageRole::User, user);
let response = model let response = model
.send_chat_request(messages) .send_chat_request(request)
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
@@ -198,10 +288,29 @@ impl CommitAiEngine {
if message.is_empty() { if message.is_empty() {
return Err("The model did not return a response.".to_string()); return Err("The model did not return a response.".to_string());
} }
if looks_like_diff_echo(&message) {
return Err(
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
);
}
{
let mut guard = self.inner.write().await;
guard.cache = Some(GenerationCache {
key: cache_key,
message: message.clone(),
});
}
Ok(message) Ok(message)
} }
} }
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
let mut hasher = DefaultHasher::new();
diff.hash(&mut hasher);
notes.unwrap_or("").hash(&mut hasher);
hasher.finish()
}
/// Models occasionally ignore the "no code fences" instruction (small local models /// Models occasionally ignore the "no code fences" instruction (small local models
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go /// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
/// straight into the commit-message box. /// straight into the commit-message box.
@@ -221,6 +330,74 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
trimmed.to_string() trimmed.to_string()
} }
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
/// sections back instead of writing a commit message. Catch that so the UI can show a
/// clear error instead of dumping raw diff text into the commit-message box.
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("diff --git")
|| lower.contains("staged files:")
|| lower.contains("staged changes:")
|| lower.contains("diff stat:")
|| lower.contains("detailed diff:")
|| message.lines().any(|line| line.starts_with("@@ "))
}
fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
if input.len() <= max_chars {
return input.to_string();
}
let mut cut = max_chars;
while !input.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &input[..cut])
}
pub(crate) fn build_local_messages(
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
// Appended to every profile below: small local models occasionally just echo the input
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
// section headers here makes the failure mode explicit enough for weak models to avoid.
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
anywhere in your answer.";
let system = match profile {
LocalGenerationProfile::Fast => {
format!(
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Balanced => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Detailed => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
};
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged changes:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> { pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() { if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string()); return Err("No staged changes available for a commit message.".to_string());
@@ -228,26 +405,35 @@ pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String,
// Rough token estimate — small models often have an 8-32k context window. // Rough token estimate — small models often have an 8-32k context window.
const MAX_CHARS: usize = 24_000; const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS { let diff = truncate_at_char_boundary(diff, MAX_CHARS);
// 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 truncated ...]", &diff[..cut])
} else {
diff.to_string()
};
let system = "You are a tool that generates Git commit messages. \ let system = "You are a tool that writes a Git commit message describing a staged diff. \
Respond only with the commit message in Conventional Commits format \ Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \
(<type>(<scope>): <subject>), followed by a body after a blank line. \ do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \
Subject in imperative mood, max. 72 characters. \ or 'Diff stat:' anywhere in your answer. \
The body is required: summarize in a short paragraph what changed and why, \ Format: a Conventional Commits header (<type>(<scope>): <subject>) in imperative mood, \
then list the key changes as bullet points (- ...), \ max. 72 characters, then a blank line, then a body. \
grouped by affected area/file. Lines in the body max. 72 characters. \ The body is required: a short, general paragraph (2-4 sentences) summarizing what changed \
No preamble, no explanation, no code fences, answer in English" and why at a high level — do NOT enumerate every changed file individually. \
You may optionally add up to 3 bullet points (- ...) afterward, but only for the most \
significant changes overall, never one bullet or heading per file. \
Never use bold text, backticks, or markdown headings for file names. \
Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English.\n\n\
Example:\n\
Diff:\n\
diff --git a/src/auth.py b/src/auth.py\n\
+def hash_password(pw):\n\
+ return bcrypt.hash(pw)\n\
diff --git a/src/routes.py b/src/routes.py\n\
-if password == stored_password:\n\
+if bcrypt.check(password, stored_password):\n\n\
Commit message:\n\
feat(auth): hash and verify passwords with bcrypt\n\n\
Passwords were previously compared as plain text. This adds a bcrypt-based\n\
hashing helper and updates the login check to verify against the hash\n\
instead of a direct string comparison.\n\n\
- Hash passwords on write, verify with bcrypt on login"
.to_string(); .to_string();
let mut user = String::new(); let mut user = String::new();
+65 -19
View File
@@ -540,6 +540,50 @@ fn staged_diff(repo: &Path) -> Result<String, String> {
Ok(format!("Staged files:\n{file_list}\n\n{diff}")) Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
} }
fn staged_diff_local(
repo: &Path,
profile: commit_ai::LocalGenerationProfile,
) -> Result<String, String> {
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?;
let stat = String::from_utf8_lossy(&stat).trim().to_string();
let diff_args = vec![
"diff",
"--cached",
"--no-ext-diff",
"--no-textconv",
profile.diff_unified_context(),
"--",
".",
":(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 = run_git(repo, diff_args)?;
let diff = String::from_utf8_lossy(&diff).trim().to_string();
let mut sections = Vec::new();
if !file_list.is_empty() {
sections.push(format!("Staged files:\n{file_list}"));
}
if !stat.is_empty() {
sections.push(format!("Diff stat:\n{stat}"));
}
if !diff.is_empty() {
sections.push(format!("Detailed diff:\n{diff}"));
}
Ok(sections.join("\n\n"))
}
#[tauri::command] #[tauri::command]
pub async fn commit_ai_generate( pub async fn commit_ai_generate(
path: String, path: String,
@@ -548,17 +592,27 @@ pub async fn commit_ai_generate(
model: Option<String>, model: Option<String>,
api_key: Option<String>, api_key: Option<String>,
base_url: Option<String>, base_url: Option<String>,
local_profile: Option<String>,
engine: tauri::State<'_, commit_ai::CommitAiEngine>, engine: tauri::State<'_, commit_ai::CommitAiEngine>,
) -> Result<String, String> { ) -> Result<String, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let diff = staged_diff(&repo)?; let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref());
let diff = if provider == "local" {
staged_diff_local(&repo, local_profile)?
} else {
staged_diff(&repo)?
};
let notes = notes.as_deref(); let notes = notes.as_deref();
let model = model.filter(|value| !value.trim().is_empty()); let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.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()); let base_url = base_url.filter(|value| !value.trim().is_empty());
match provider.as_str() { match provider.as_str() {
"local" => engine.generate_commit_message(&diff, notes).await, "local" => {
engine
.generate_commit_message(&diff, notes, local_profile)
.await
}
"openai" => { "openai" => {
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?; let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string()); let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
@@ -620,9 +674,7 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) { if has_unresolved_conflicts(&current_status) {
return Err( return Err("Merge conflicts must be resolved before you can commit.".to_string());
"Merge conflicts must be resolved before you can commit.".to_string(),
);
} }
run_git(&repo, ["commit", "-m", message.as_str()])?; run_git(&repo, ["commit", "-m", message.as_str()])?;
@@ -646,9 +698,7 @@ pub fn pull(
.arg(&repo) .arg(&repo)
.args(pull_args) .args(pull_args)
.output() .output()
.map_err(|err| { .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
format!("Could not start Git. Is Git installed? {err}")
})?,
}; };
if output.status.success() { if output.status.success() {
@@ -703,8 +753,7 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
if key.is_empty() { if key.is_empty() {
return Err("No key provided for the credentials.".to_string()); return Err("No key provided for the credentials.".to_string());
} }
keyring::Entry::new(CRED_SERVICE, key) keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
.map_err(|err| format!("Keychain unavailable: {err}"))
} }
/// Returns the remote URL used for auth key derivation (upstream remote of the /// Returns the remote URL used for auth key derivation (upstream remote of the
@@ -790,9 +839,8 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
return Ok("origin".to_string()); return Ok("origin".to_string());
} }
first_remote_name(repo).ok_or_else(|| { first_remote_name(repo)
"This branch has no upstream and no remote is configured.".to_string() .ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
})
} }
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> { fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
@@ -2554,9 +2602,7 @@ fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<Stri
let normalized = validate_branch_ref_name(branch)?; let normalized = validate_branch_ref_name(branch)?;
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? { if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!( return Err(format!("Local branch '{normalized}' was not found."));
"Local branch '{normalized}' was not found."
));
} }
Ok(normalized) Ok(normalized)
@@ -2996,8 +3042,8 @@ where
thread::sleep(Duration::from_millis(60)); thread::sleep(Duration::from_millis(60));
}; };
let stdout = std::fs::read(&stdout_path) let stdout =
.map_err(|err| format!("Could not read Git output: {err}"))?; std::fs::read(&stdout_path).map_err(|err| format!("Could not read Git output: {err}"))?;
let stderr = std::fs::read(&stderr_path) let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Could not read Git error output: {err}"))?; .map_err(|err| format!("Could not read Git error output: {err}"))?;
let _ = std::fs::remove_file(&stdout_path); let _ = std::fs::remove_file(&stdout_path);
@@ -4179,7 +4225,7 @@ mod tests {
); );
let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err(); let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err();
assert!(err.contains("aktuelle Branch")); assert!(err.contains("current branch"));
} }
#[test] #[test]
+28 -5
View File
@@ -141,6 +141,7 @@
let activeFileHistoryRequestId = ""; let activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = ""; let lastFileHistoryHeadHash = "";
let commitMessage = ""; let commitMessage = "";
let lastLocalAiGeneratedMessage = "";
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;
@@ -322,6 +323,13 @@
startCommitAiPolling(); startCommitAiPolling();
} }
function updateCommitMessage(message: string) {
commitMessage = message;
if (message !== lastLocalAiGeneratedMessage) {
lastLocalAiGeneratedMessage = "";
}
}
async function generateCommitMessageWithAi() { async function generateCommitMessageWithAi() {
if (!activeRepoPath || commitAiGenerating) return; if (!activeRepoPath || commitAiGenerating) return;
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return; if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
@@ -330,7 +338,13 @@
try { try {
const notes = commitMessage.trim() || undefined; const notes = commitMessage.trim() || undefined;
if (aiSettings.provider === "local") { if (aiSettings.provider === "local") {
commitMessage = await commitAiGenerate(activeRepoPath, { provider: "local", notes }); const localNotes = notes && notes !== lastLocalAiGeneratedMessage ? notes : undefined;
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "local",
notes: localNotes,
localProfile: aiSettings.localProfile,
});
lastLocalAiGeneratedMessage = commitMessage;
} else if (aiSettings.provider === "openai") { } else if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai"); const cred = await credLoad("ai:openai");
commitMessage = await commitAiGenerate(activeRepoPath, { commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -339,6 +353,7 @@
model: aiSettings.openaiModel, model: aiSettings.openaiModel,
apiKey: cred?.password, apiKey: cred?.password,
}); });
lastLocalAiGeneratedMessage = "";
} else if (aiSettings.provider === "anthropic") { } else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic"); const cred = await credLoad("ai:anthropic");
commitMessage = await commitAiGenerate(activeRepoPath, { commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -347,6 +362,7 @@
model: aiSettings.anthropicModel, model: aiSettings.anthropicModel,
apiKey: cred?.password, apiKey: cred?.password,
}); });
lastLocalAiGeneratedMessage = "";
} else { } else {
const cred = await credLoad("ai:custom"); const cred = await credLoad("ai:custom");
commitMessage = await commitAiGenerate(activeRepoPath, { commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -356,6 +372,7 @@
baseUrl: aiSettings.customBaseUrl, baseUrl: aiSettings.customBaseUrl,
apiKey: cred?.password, apiKey: cred?.password,
}); });
lastLocalAiGeneratedMessage = "";
} }
} catch (error) { } catch (error) {
errorMessage = errorToMessage(error); errorMessage = errorToMessage(error);
@@ -526,8 +543,9 @@
function defaultAiSettings(): AiSettings { function defaultAiSettings(): AiSettings {
return { return {
provider: "local", provider: "openai",
localModelId: "qwen2.5-1.5b", localModelId: "qwen2.5-0.5b",
localProfile: "fast",
openaiModel: "gpt-4o-mini", openaiModel: "gpt-4o-mini",
anthropicModel: "claude-3-5-haiku-latest", anthropicModel: "claude-3-5-haiku-latest",
customBaseUrl: "", customBaseUrl: "",
@@ -539,7 +557,11 @@
try { try {
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown; const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
if (stored && typeof stored === "object") { if (stored && typeof stored === "object") {
return { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) }; const merged = { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
// Local AI is still in development and disabled in the settings UI — migrate any
// previously saved selection away from it so nobody gets stuck on a dead option.
if (merged.provider === "local") merged.provider = "openai";
return merged;
} }
} catch { } catch {
// Fall through to defaults below. // Fall through to defaults below.
@@ -1339,6 +1361,7 @@
await runOperation("Committing", async () => { await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message)); applyStatus(await commit(activeRepoPath, message));
commitMessage = ""; commitMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
@@ -1968,7 +1991,7 @@
{commitAiPhase} {commitAiPhase}
{commitAiGenerating} {commitAiGenerating}
onCommit={commitChanges} onCommit={commitChanges}
onCommitMessageChange={(msg) => { commitMessage = msg; }} onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi} onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }} onOpenAiSettings={() => { aiSettingsOpen = true; }}
/> />
+38
View File
@@ -1721,6 +1721,44 @@
color: #f5f7ff; color: #f5f7ff;
background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1)); background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1));
} }
.ai-provider-option-local {
flex-wrap: wrap;
row-gap: 2px;
}
.ai-provider-badge {
flex-basis: 100%;
text-align: center;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-ink-faint);
}
.ai-local-profile-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ai-local-profile-option {
min-width: 0;
min-height: 34px;
padding: 0 8px;
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.03);
color: var(--color-ink-dim);
font-size: 12px;
font-weight: 700;
}
.ai-local-profile-option:hover:not(:disabled) {
border-color: var(--color-border);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.ai-local-profile-option.active {
border-color: rgba(65,209,255,0.48);
color: #f5f7ff;
background: linear-gradient(180deg, rgba(65,209,255,0.16), rgba(100,108,255,0.12));
}
.new-branch-form { .new-branch-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+46 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte"; import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git"; import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiProvider, LocalModelOption } from "../types"; import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
interface Props { interface Props {
settings: AiSettings; settings: AiSettings;
@@ -23,6 +23,7 @@
let provider = $state<CommitAiProvider>("local"); let provider = $state<CommitAiProvider>("local");
let localModelId = $state(""); let localModelId = $state("");
let localProfile = $state<CommitAiLocalProfile>("fast");
let openaiModel = $state(""); let openaiModel = $state("");
let anthropicModel = $state(""); let anthropicModel = $state("");
let customBaseUrl = $state(""); let customBaseUrl = $state("");
@@ -39,6 +40,7 @@
$effect(() => { $effect(() => {
provider = settings.provider; provider = settings.provider;
localModelId = settings.localModelId; localModelId = settings.localModelId;
localProfile = settings.localProfile ?? "fast";
openaiModel = settings.openaiModel; openaiModel = settings.openaiModel;
anthropicModel = settings.anthropicModel; anthropicModel = settings.anthropicModel;
customBaseUrl = settings.customBaseUrl; customBaseUrl = settings.customBaseUrl;
@@ -86,6 +88,7 @@
onSave({ onSave({
provider, provider,
localModelId, localModelId,
localProfile,
openaiModel: openaiModel.trim() || "gpt-4o-mini", openaiModel: openaiModel.trim() || "gpt-4o-mini",
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest", anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
customBaseUrl: customBaseUrl.trim(), customBaseUrl: customBaseUrl.trim(),
@@ -102,6 +105,21 @@
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`; return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
} }
function recommendedModelForProfile(profile: CommitAiLocalProfile): string {
if (profile === "balanced") return "qwen2.5-1.5b";
if (profile === "detailed") return "qwen2.5-3b";
return "qwen2.5-0.5b";
}
function selectLocalProfile(profile: CommitAiLocalProfile) {
const previousRecommended = recommendedModelForProfile(localProfile);
localProfile = profile;
const nextRecommended = recommendedModelForProfile(profile);
if (!localModelId || localModelId === previousRecommended) {
localModelId = nextRecommended;
}
}
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId)); let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
</script> </script>
@@ -123,9 +141,16 @@
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}> <form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider"> <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"; }}> <button
type="button"
class="ai-provider-option ai-provider-option-local"
class:active={provider === "local"}
disabled
title="Local AI is still in development and not yet available"
>
<Cpu size={16} aria-hidden="true" /> <Cpu size={16} aria-hidden="true" />
Local AI Local AI
<span class="ai-provider-badge">In development</span>
</button> </button>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}> <button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" /> <Bot size={16} aria-hidden="true" />
@@ -142,6 +167,23 @@
</div> </div>
{#if provider === "local"} {#if provider === "local"}
<div class="cred-field">
<span class="cred-field-label">Local speed</span>
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
<Zap size={15} aria-hidden="true" />
Fast
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
<Gauge size={15} aria-hidden="true" />
Balanced
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
<Sparkles size={15} aria-hidden="true" />
Detailed
</button>
</div>
</div>
<label class="cred-field"> <label class="cred-field">
<span class="cred-field-label">Model</span> <span class="cred-field-label">Model</span>
<select bind:value={localModelId}> <select bind:value={localModelId}>
@@ -156,6 +198,7 @@
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""} Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
in the background — depending on your internet connection this can take several minutes. in the background — depending on your internet connection this can take several minutes.
After that it stays cached locally and loads instantly on the next start. After that it stays cached locally and loads instantly on the next start.
The speed setting only changes Local AI; API providers keep their existing prompt.
</span> </span>
</div> </div>
{:else if provider === "openai"} {:else if provider === "openai"}
+12 -2
View File
@@ -113,6 +113,16 @@
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path; return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
} }
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function commitFileName(file: GitCommitFile): string {
return file.old_path
? `${baseName(file.old_path)} -> ${baseName(file.path)}`
: baseName(file.path);
}
function formatCommitDate(value: string): string { function formatCommitDate(value: string): string {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
@@ -208,10 +218,10 @@
type="button" type="button"
onclick={() => onPreviewCommitFile(item, file)} onclick={() => onPreviewCommitFile(item, file)}
disabled={isBusy} disabled={isBusy}
title="Show differences before restoring" title={`Show differences before restoring - ${displayCommitFile(file)}`}
> >
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span> <span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{displayCommitFile(file)}</strong> <strong>{commitFileName(file)}</strong>
</button> </button>
{/each} {/each}
</div> </div>
+3
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { import type {
CommitAiLocalProfile,
CommitAiProvider, CommitAiProvider,
CommitAiStatus, CommitAiStatus,
ConflictFile, ConflictFile,
@@ -112,6 +113,7 @@ export function commitAiLocalModels(): Promise<LocalModelOption[]> {
export interface CommitAiGenerateOptions { export interface CommitAiGenerateOptions {
notes?: string; notes?: string;
provider: CommitAiProvider; provider: CommitAiProvider;
localProfile?: CommitAiLocalProfile;
model?: string; model?: string;
apiKey?: string; apiKey?: string;
baseUrl?: string; baseUrl?: string;
@@ -122,6 +124,7 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
path, path,
notes: options.notes, notes: options.notes,
provider: options.provider, provider: options.provider,
localProfile: options.localProfile,
model: options.model, model: options.model,
apiKey: options.apiKey, apiKey: options.apiKey,
baseUrl: options.baseUrl, baseUrl: options.baseUrl,
+2
View File
@@ -9,6 +9,7 @@ export type FileStatusKind =
export type CommitAiPhase = "idle" | "loading" | "ready" | "error"; export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom"; export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export interface CommitAiStatus { export interface CommitAiStatus {
phase: CommitAiPhase; phase: CommitAiPhase;
@@ -25,6 +26,7 @@ export interface LocalModelOption {
export interface AiSettings { export interface AiSettings {
provider: CommitAiProvider; provider: CommitAiProvider;
localModelId: string; localModelId: string;
localProfile: CommitAiLocalProfile;
openaiModel: string; openaiModel: string;
anthropicModel: string; anthropicModel: string;
customBaseUrl: string; customBaseUrl: string;