feat(llm): 生成器与连接测试切换到新 rig 门面(全量上线)

- 新门面补齐 commit/tag/changelog 三个业务方法(提示词与解析复用迁移模块)
- ContentGenerator 改用 rig 门面(构造/可用性检查/三个生成方法)
- test_connection 走新门面公开接口,不再访问私有字段
- 新增离线业务方法测试与 6 家 provider 的 #[ignore] 实网冒烟用例
This commit is contained in:
2026-08-17 15:56:56 +08:00
parent 9f177f7a1f
commit 16ffc94a06
3 changed files with 269 additions and 4 deletions

View File

@@ -1,7 +1,8 @@
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
use crate::config::{CommitFormat, Language}; use crate::config::{CommitFormat, Language};
use crate::git::{CommitInfo, GitRepo}; use crate::git::{CommitInfo, GitRepo};
use crate::llm::{GeneratedCommit, LlmClient}; use crate::llm::parsing::GeneratedCommit;
use crate::llm::rig::LlmClient;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
/// Content generator using LLM /// Content generator using LLM

View File

@@ -322,7 +322,6 @@ pub(crate) fn create_http_client(timeout: Duration) -> Result<reqwest::Client> {
/// Test LLM connection /// Test LLM connection
pub async fn test_connection(manager: &crate::config::manager::ConfigManager) -> Result<String> { pub async fn test_connection(manager: &crate::config::manager::ConfigManager) -> Result<String> {
let client = LlmClient::from_config(manager).await?; let client = crate::llm::rig::LlmClient::from_config(manager).await?;
let response = client.provider.generate("Say 'Hello, World!'").await?; client.generate(None, "Say 'Hello, World!'").await
Ok(response)
} }

View File

@@ -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<crate::llm::parsing::GeneratedCommit> {
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<String> {
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<String> {
let system_prompt = crate::llm::prompts::get_changelog_system_prompt(language);
let commits_text = commits
.iter()
.map(|(t, m)| format!("- [{}] {}", t, m))
.collect::<Vec<_>>()
.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<M: CompletionModel>( async fn run<M: CompletionModel>(
&self, &self,
@@ -1188,4 +1301,156 @@ mod tests {
assert_eq!(start_count.load(Ordering::SeqCst), 1); assert_eq!(start_count.load(Ordering::SeqCst), 1);
assert_eq!(end_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());
}
} }