diff --git a/src/generator/mod.rs b/src/generator/mod.rs index fd95189..26bf274 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -1,7 +1,8 @@ use crate::config::manager::ConfigManager; use crate::config::{CommitFormat, Language}; use crate::git::{CommitInfo, GitRepo}; -use crate::llm::{GeneratedCommit, LlmClient}; +use crate::llm::parsing::GeneratedCommit; +use crate::llm::rig::LlmClient; use anyhow::{Context, Result}; /// Content generator using LLM diff --git a/src/llm/mod.rs b/src/llm/mod.rs index c917455..8dd2724 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -322,7 +322,6 @@ pub(crate) fn create_http_client(timeout: Duration) -> Result { /// Test LLM connection pub async fn test_connection(manager: &crate::config::manager::ConfigManager) -> Result { - let client = LlmClient::from_config(manager).await?; - let response = client.provider.generate("Say 'Hello, World!'").await?; - Ok(response) + let client = crate::llm::rig::LlmClient::from_config(manager).await?; + client.generate(None, "Say 'Hello, World!'").await } \ No newline at end of file diff --git a/src/llm/rig/mod.rs b/src/llm/rig/mod.rs index d3be321..187daac 100644 --- a/src/llm/rig/mod.rs +++ b/src/llm/rig/mod.rs @@ -312,6 +312,119 @@ where } } + /// 从 git diff 生成 commit 消息(提示词与解析复用迁移后的模块)。 + pub async fn generate_commit_message( + &self, + diff: &str, + format: crate::config::CommitFormat, + language: crate::config::Language, + template: Option<&str>, + ) -> Result { + let mut system_prompt = + crate::llm::prompts::get_commit_system_prompt(format, language).to_string(); + + if let Some(tmpl) = template { + system_prompt.push_str(&format!( + "\n\n## Commit Message Template\nFollow this template structure:\n{}", + tmpl + )); + } + + let language_instruction = match language { + crate::config::Language::Chinese => "\n\n请用中文生成提交消息。", + crate::config::Language::Japanese => "\n\n日本語でコミットメッセージを生成してください。", + crate::config::Language::Korean => "\n\n한국어로 커밋 메시지를 생성하세요.", + crate::config::Language::Spanish => { + "\n\nPor favor, genera el mensaje de commit en español." + } + crate::config::Language::French => { + "\n\nVeuillez générer le message de commit en français." + } + crate::config::Language::German => { + "\n\nBitte generieren Sie die Commit-Nachricht auf Deutsch." + } + crate::config::Language::English => "", + }; + + let prompt = format!("{}{}", diff, language_instruction); + let response = self.generate(Some(&system_prompt), &prompt).await?; + + crate::llm::parsing::parse_commit_response(&response, format) + } + + /// 从提交列表生成 tag 消息。 + pub async fn generate_tag_message( + &self, + version: &str, + commits: &[String], + language: crate::config::Language, + ) -> Result { + let system_prompt = crate::llm::prompts::get_tag_system_prompt(language); + let commits_text = commits.join("\n"); + + let language_instruction = match language { + crate::config::Language::Chinese => "\n\n请用中文生成标签消息。", + crate::config::Language::Japanese => "\n\n日本語でタグメッセージを生成してください。", + crate::config::Language::Korean => "\n\n한국어로 태그 메시지를 생성하세요.", + crate::config::Language::Spanish => { + "\n\nPor favor, genera el mensaje de etiqueta en español." + } + crate::config::Language::French => { + "\n\nVeuillez générer le message de balise en français." + } + crate::config::Language::German => { + "\n\nBitte generieren Sie die Tag-Nachricht auf Deutsch." + } + crate::config::Language::English => "", + }; + + let prompt = format!( + "Version: {}\n\nCommits:\n{}{}", + version, commits_text, language_instruction + ); + + self.generate(Some(system_prompt), &prompt).await + } + + /// 从带类型的提交列表生成 changelog 条目。 + pub async fn generate_changelog_entry( + &self, + version: &str, + commits: &[(String, String)], + language: crate::config::Language, + ) -> Result { + let system_prompt = crate::llm::prompts::get_changelog_system_prompt(language); + + let commits_text = commits + .iter() + .map(|(t, m)| format!("- [{}] {}", t, m)) + .collect::>() + .join("\n"); + + let language_instruction = match language { + crate::config::Language::Chinese => "\n\n请用中文生成变更日志。", + crate::config::Language::Japanese => "\n\n日本語で変更ログを生成してください。", + crate::config::Language::Korean => "\n\n한국어로 변경 로그를 생성하세요.", + crate::config::Language::Spanish => { + "\n\nPor favor, genera el registro de cambios en español." + } + crate::config::Language::French => { + "\n\nVeuillez générer le journal des modifications en français." + } + crate::config::Language::German => { + "\n\nBitte generieren Sie das Changelog auf Deutsch." + } + crate::config::Language::English => "", + }; + + let prompt = format!( + "Version: {}\n\nCommits:\n{}{}", + version, commits_text, language_instruction + ); + + self.generate(Some(system_prompt), &prompt).await + } + /// 按请求参数选择流式/非流式路径。 async fn run( &self, @@ -1188,4 +1301,156 @@ mod tests { assert_eq!(start_count.load(Ordering::SeqCst), 1); assert_eq!(end_count.load(Ordering::SeqCst), 1); } + + // ---- 业务方法(离线)---- + + #[tokio::test] + async fn generate_commit_message_assembles_prompt_and_parses() { + let recorder = RecordingHttpClient::new( + r#"{"id":"cmpl-1","model":"deepseek-v4-flash","choices":[{"index":0,"message":{"role":"assistant","content":"feat(parser): add commit parser"},"finish_reason":"stop"}],"usage":{"completion_tokens":6,"prompt_tokens":3,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":0,"total_tokens":9}}"#, + ); + let (client, recorder) = deepseek_client(recorder, false); + + let commit = client + .generate_commit_message( + "diff --git a/x b/x", + crate::config::CommitFormat::Conventional, + crate::config::Language::Chinese, + None, + ) + .await + .unwrap(); + + assert_eq!(commit.commit_type, "feat"); + assert_eq!(commit.scope.as_deref(), Some("parser")); + assert_eq!(commit.description, "add commit parser"); + + let captured = recorder.requests(); + let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); + let system = body["messages"][0]["content"].as_str().unwrap(); + assert!(system.contains("Conventional Commits"), "system prompt 应包含规范说明"); + let user = body["messages"][1]["content"].as_str().unwrap(); + assert!(user.contains("diff --git"), "user prompt 应包含 diff"); + assert!(user.contains("请用中文生成提交消息"), "应包含语言指令"); + } + + // ---- 实网冒烟(#[ignore],需真实服务/API key)---- + + fn smoke_client( + backend: Backend, + model: &str, + provider: &str, + ) -> LlmClient { + LlmClient::new(backend, model, provider, LlmClientConfig::default(), false, None) + } + + fn smoke_http() -> ReqwestClient { + ReqwestClient::builder() + .timeout(Duration::from_secs(60)) + .build() + .expect("build smoke http client") + } + + #[ignore = "requires a running local Ollama"] + #[tokio::test] + async fn live_smoke_ollama() { + let backend = Backend::Ollama( + ollama::Client::builder() + .api_key(Nothing) + .base_url("http://localhost:11434") + .http_client(smoke_http()) + .build() + .expect("build ollama client"), + ); + let client = smoke_client(backend, "llama3.2", "ollama"); + let text = client.generate(None, "Say 'Hello, World!'").await.unwrap(); + assert!(!text.is_empty()); + } + + #[ignore = "requires QUICOMMIT_API_KEY with a valid DeepSeek key"] + #[tokio::test] + async fn live_smoke_deepseek() { + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let backend = Backend::DeepSeek( + deepseek::Client::builder() + .api_key(&key) + .base_url("https://api.deepseek.com/v1") + .http_client(smoke_http()) + .build() + .expect("build deepseek client"), + ); + let client = smoke_client(backend, "deepseek-v4-flash", "deepseek"); + let text = client.generate(None, "Say 'Hello, World!'").await.unwrap(); + assert!(!text.is_empty()); + } + + #[ignore = "requires QUICOMMIT_API_KEY with a valid Moonshot key"] + #[tokio::test] + async fn live_smoke_kimi() { + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let backend = Backend::Kimi( + moonshot::Client::builder() + .api_key(&key) + .base_url("https://api.moonshot.cn/v1") + .http_client(smoke_http()) + .build() + .expect("build kimi client"), + ); + let client = smoke_client(backend, "kimi-k2.6", "kimi"); + let text = client.generate(None, "Say 'Hello, World!'").await.unwrap(); + assert!(!text.is_empty()); + } + + #[ignore = "requires QUICOMMIT_API_KEY with a valid Anthropic key"] + #[tokio::test] + async fn live_smoke_anthropic() { + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let backend = Backend::Anthropic( + anthropic::Client::builder() + .api_key(&key) + .base_url("https://api.anthropic.com/v1") + .http_client(smoke_http()) + .build() + .expect("build anthropic client"), + ); + let client = smoke_client(backend, "claude-sonnet-4-6", "anthropic"); + let text = client.generate(None, "Say 'Hello, World!'").await.unwrap(); + assert!(!text.is_empty()); + } + + #[ignore = "requires QUICOMMIT_API_KEY with a valid OpenAI key"] + #[tokio::test] + async fn live_smoke_openai() { + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let backend = Backend::OpenAi( + openai::Client::builder() + .api_key(&key) + .base_url("https://api.openai.com/v1") + .http_client(smoke_http()) + .build() + .map(|client| client.completions_api()) + .expect("build openai client"), + ); + let client = smoke_client(backend, "gpt-4o-mini", "openai"); + let text = client.generate(None, "Say 'Hello, World!'").await.unwrap(); + assert!(!text.is_empty()); + } + + #[ignore = "requires QUICOMMIT_API_KEY with a valid OpenRouter key"] + #[tokio::test] + async fn live_smoke_openrouter() { + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let backend = Backend::OpenRouter( + openrouter::Client::builder() + .api_key(&key) + .base_url("https://openrouter.ai/api/v1") + .with_app_identity("QuiCommit", "https://quicommit.dev") + .http_client(smoke_http()) + .build() + .expect("build openrouter client"), + ); + let client = smoke_client(backend, "openai/gpt-4o-mini", "openrouter"); + let text = client.generate(None, "Say 'Hello, World!'").await.unwrap(); + assert!(!text.is_empty()); + } } \ No newline at end of file