feat(changelog): 添加 --no-generate 标志以支持纯模板生成
- 新增 `--no-generate` 参数,强制使用模板生成而非 AI - 调整 `--yes` 参数仅跳过交互提示,不改变生成行为 - 统一使用工具函数替代 println 输出
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,18 +64,10 @@ impl Default for ThinkingStateManager {
|
||||
/// 线程安全的思考状态管理器引用
|
||||
pub type SharedThinkingState = Arc<ThinkingStateManager>;
|
||||
|
||||
/// 创建带有默认控制台输出的思考状态管理器
|
||||
/// 在思考开始时打印 "thinking...",在思考结束时清除该标识
|
||||
/// 创建 LLM 流式使用的共享思考状态。
|
||||
/// 进度显示由 AI 生成 spinner 负责(issue 21),此处不再附加控制台输出。
|
||||
pub fn create_console_thinking_state() -> SharedThinkingState {
|
||||
Arc::new(
|
||||
ThinkingStateManager::new()
|
||||
.on_thinking_start(|| {
|
||||
eprint!("\rthinking...");
|
||||
})
|
||||
.on_thinking_end(|| {
|
||||
eprint!("\r \r");
|
||||
}),
|
||||
)
|
||||
Arc::new(ThinkingStateManager::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user