feat(changelog): 添加 --no-generate 标志以支持纯模板生成

- 新增 `--no-generate` 参数,强制使用模板生成而非 AI
- 调整 `--yes` 参数仅跳过交互提示,不改变生成行为
- 统一使用工具函数替代 println 输出
This commit is contained in:
2026-08-21 13:57:25 +08:00
parent e6b8b344aa
commit a40c88c768
22 changed files with 5709 additions and 1473 deletions

View File

@@ -4,7 +4,7 @@
//! 全部 provider 接入并切换后,旧实现将被删除。
use crate::config::manager::ConfigManager;
use crate::llm::thinking::{create_console_thinking_state, ThinkingStateManager};
use crate::llm::thinking::{ThinkingStateManager, create_console_thinking_state};
use anyhow::{Context, Result, bail};
use rig_core::{
client::{CompletionClient, Nothing, VerifyClient},
@@ -154,7 +154,8 @@ fn required_api_key(provider: &str, key: Option<String>) -> Result<String> {
}
impl<H> LlmClient<H> {
/// 内部与测试构造入口。
/// 内部与测试构造入口(仅测试使用)
#[cfg(test)]
pub(crate) fn new(
backend: Backend<H>,
model: impl Into<String>,
@@ -332,7 +333,9 @@ where
let language_instruction = match language {
crate::config::Language::Chinese => "\n\n请用中文生成提交消息。",
crate::config::Language::Japanese => "\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."
@@ -465,7 +468,8 @@ where
let event = event.map_err(|e| map_completion_error(&self.provider, e))?;
match event {
StreamedAssistantContent::Text(chunk) => {
if has_reasoning && !has_content
if has_reasoning
&& !has_content
&& let Some(state) = state
{
state.end_thinking();
@@ -474,7 +478,9 @@ where
text.push_str(&chunk.text);
}
StreamedAssistantContent::Reasoning(_)
| StreamedAssistantContent::ReasoningDelta { .. } if !has_reasoning => {
| StreamedAssistantContent::ReasoningDelta { .. }
if !has_reasoning =>
{
has_reasoning = true;
if let Some(state) = state {
state.start_thinking();
@@ -519,7 +525,11 @@ fn build_ollama_client(base_url: &str, http: ReqwestClient) -> Result<ollama::Cl
}
/// 构建 DeepSeek 客户端OpenAI 兼容 API
fn build_deepseek_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<deepseek::Client> {
fn build_deepseek_client(
key: &str,
base_url: &str,
http: ReqwestClient,
) -> Result<deepseek::Client> {
deepseek::Client::builder()
.api_key(key)
.base_url(base_url)
@@ -539,7 +549,11 @@ fn build_kimi_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<m
}
/// 构建 Anthropic 客户端rig 会自动规范化 base_url 的 /v1 等后缀)。
fn build_anthropic_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<anthropic::Client> {
fn build_anthropic_client(
key: &str,
base_url: &str,
http: ReqwestClient,
) -> Result<anthropic::Client> {
anthropic::Client::builder()
.api_key(key)
.base_url(base_url)
@@ -564,7 +578,11 @@ fn build_openai_client(
}
/// 构建 OpenRouter 客户端(携带 QuiCommit 应用标识头)。
fn build_openrouter_client(key: &str, base_url: &str, http: ReqwestClient) -> Result<openrouter::Client> {
fn build_openrouter_client(
key: &str,
base_url: &str,
http: ReqwestClient,
) -> Result<openrouter::Client> {
openrouter::Client::builder()
.api_key(key)
.base_url(base_url)
@@ -713,8 +731,14 @@ mod tests {
.build()
.expect("build deepseek client with mock backend"),
);
let client =
LlmClient::new(backend, "deepseek-v4-flash", "deepseek", test_config(), thinking, None);
let client = LlmClient::new(
backend,
"deepseek-v4-flash",
"deepseek",
test_config(),
thinking,
None,
);
(client, recorder)
}
@@ -817,7 +841,10 @@ mod tests {
assert_eq!(text, "hello from deepseek");
let captured = recorder.requests();
assert_eq!(captured[0].uri, "https://api.deepseek.com/v1/chat/completions");
assert_eq!(
captured[0].uri,
"https://api.deepseek.com/v1/chat/completions"
);
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(body["model"], "deepseek-v4-flash");
// 非流式请求省略 stream 字段API 默认 false
@@ -843,7 +870,10 @@ mod tests {
client.generate(None, "hi").await.unwrap();
let captured = recorder.requests();
assert_eq!(captured[0].uri, "https://api.moonshot.cn/v1/chat/completions");
assert_eq!(
captured[0].uri,
"https://api.moonshot.cn/v1/chat/completions"
);
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(body["temperature"], 0.6);
assert_eq!(body["thinking"]["type"], "disabled");
@@ -882,7 +912,9 @@ mod tests {
deepseek::Client::builder()
.api_key("sk-test")
.base_url("https://api.deepseek.com/v1")
.http_client(MockStreamingClient { sse_bytes: sse.to_string().into() })
.http_client(MockStreamingClient {
sse_bytes: sse.to_string().into(),
})
.build()
.expect("build deepseek client with streaming mock"),
);
@@ -962,9 +994,10 @@ mod tests {
#[test]
fn map_error_without_body_keeps_provider_prefix() {
let err = map_completion_error("DeepSeek", CompletionError::ProviderError("boom".into()));
assert!(err
.to_string()
.contains("DeepSeek API request failed: ProviderError: boom"));
assert!(
err.to_string()
.contains("DeepSeek API request failed: ProviderError: boom")
);
}
#[test]
@@ -980,10 +1013,16 @@ mod tests {
#[test]
fn supports_thinking_whitelist_matches_legacy() {
for provider in ["deepseek", "kimi", "anthropic", "openai"] {
assert!(supports_thinking(provider), "{provider} should support thinking");
assert!(
supports_thinking(provider),
"{provider} should support thinking"
);
}
for provider in ["ollama", "openrouter"] {
assert!(!supports_thinking(provider), "{provider} should not support thinking");
assert!(
!supports_thinking(provider),
"{provider} should not support thinking"
);
}
}
@@ -1015,7 +1054,14 @@ mod tests {
);
let mut config = test_config();
config.thinking_budget_tokens = budget;
let client = LlmClient::new(backend, "claude-sonnet-4-6", "anthropic", config, thinking, None);
let client = LlmClient::new(
backend,
"claude-sonnet-4-6",
"anthropic",
config,
thinking,
None,
);
(client, recorder)
}
@@ -1111,7 +1157,9 @@ mod tests {
anthropic::Client::builder()
.api_key("sk-ant-test")
.base_url("https://api.anthropic.com/v1")
.http_client(MockStreamingClient { sse_bytes: sse.to_string().into() })
.http_client(MockStreamingClient {
sse_bytes: sse.to_string().into(),
})
.build()
.expect("build anthropic client with streaming mock"),
);
@@ -1186,12 +1234,18 @@ mod tests {
assert_eq!(text, "hello from openai");
let captured = recorder.requests();
assert_eq!(captured[0].uri, "https://api.openai.com/v1/chat/completions");
assert_eq!(
captured[0].uri,
"https://api.openai.com/v1/chat/completions"
);
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(body["model"], "gpt-4o");
assert_eq!(body["temperature"], 0.5);
assert_eq!(body["max_tokens"], 123);
assert!(body.get("reasoning_effort").is_none(), "非 o 系列不应传 reasoning_effort");
assert!(
body.get("reasoning_effort").is_none(),
"非 o 系列不应传 reasoning_effort"
);
}
#[test]
@@ -1200,7 +1254,9 @@ mod tests {
let (client, _) = openai_client(recorder, "o3", false);
let options = client.request_options();
let params = options.additional_params.expect("o 系列应传 reasoning_effort");
let params = options
.additional_params
.expect("o 系列应传 reasoning_effort");
assert_eq!(params["reasoning_effort"], "none");
assert!(!options.stream);
}
@@ -1328,7 +1384,10 @@ mod tests {
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 应包含规范说明");
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("请用中文生成提交消息"), "应包含语言指令");
@@ -1336,12 +1395,15 @@ mod tests {
// ---- 实网冒烟(#[ignore],需真实服务/API key----
fn smoke_client(
backend: Backend,
model: &str,
provider: &str,
) -> LlmClient {
LlmClient::new(backend, model, provider, LlmClientConfig::default(), false, None)
fn smoke_client(backend: Backend, model: &str, provider: &str) -> LlmClient {
LlmClient::new(
backend,
model,
provider,
LlmClientConfig::default(),
false,
None,
)
}
fn smoke_http() -> ReqwestClient {
@@ -1370,7 +1432,9 @@ mod tests {
#[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 Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::DeepSeek(
deepseek::Client::builder()
.api_key(&key)
@@ -1387,7 +1451,9 @@ mod tests {
#[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 Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::Kimi(
moonshot::Client::builder()
.api_key(&key)
@@ -1404,7 +1470,9 @@ mod tests {
#[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 Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::Anthropic(
anthropic::Client::builder()
.api_key(&key)
@@ -1421,7 +1489,9 @@ mod tests {
#[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 Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::OpenAi(
openai::Client::builder()
.api_key(&key)
@@ -1439,7 +1509,9 @@ mod tests {
#[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 Ok(key) = std::env::var("QUICOMMIT_API_KEY") else {
return;
};
let backend = Backend::OpenRouter(
openrouter::Client::builder()
.api_key(&key)
@@ -1453,4 +1525,4 @@ mod tests {
let text = client.generate(None, "Say 'Hello, World!'").await.unwrap();
assert!(!text.is_empty());
}
}
}