From dc795b26e696df7a136576053922fb22fdb5041a Mon Sep 17 00:00:00 2001 From: Gabriel Bao Date: Wed, 27 May 2026 19:57:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20=E6=9E=B6=E6=9E=84=E6=9B=B4?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 60 ++ Cargo.toml | 5 +- src/core/{action => agent}/mod.rs | 0 src/core/chat/mod.rs | 2 - src/core/chat/provider/deepseek/enums.rs | 38 - src/core/chat/provider/deepseek/message.rs | 47 -- src/core/chat/provider/deepseek/mod.rs | 6 - src/core/chat/provider/deepseek/test.rs | 281 -------- src/core/chat/provider/deepseek/tool.rs | 49 -- .../provider/anthropic => config}/mod.rs | 0 src/core/db/mod.rs | 2 +- .../{chat/provider/common => errors}/mod.rs | 0 src/core/mod.rs | 12 +- src/core/providers/anthropic/mod.rs | 21 + .../anthropic/model.rs} | 0 .../basic/completions.rs} | 129 +++- .../mod.rs => providers/basic/messages.rs} | 0 src/core/providers/basic/mod.rs | 9 + .../mod.rs => providers/basic/responses.rs} | 0 src/core/providers/deepseek/mod.rs | 19 + .../provider => providers}/deepseek/model.rs | 0 src/core/{chat/provider => providers}/mod.rs | 4 +- src/core/providers/openai/mod.rs | 20 + .../mod.rs => providers/openai/model.rs} | 0 src/core/providers/openai/request.rs | 6 + src/core/{update => runtime}/mod.rs | 0 src/core/tools/mod.rs | 0 src/core/transport/client.rs | 654 ++++++++++++++++++ src/core/transport/mod.rs | 1 + 29 files changed, 931 insertions(+), 434 deletions(-) rename src/core/{action => agent}/mod.rs (100%) delete mode 100644 src/core/chat/mod.rs delete mode 100644 src/core/chat/provider/deepseek/enums.rs delete mode 100644 src/core/chat/provider/deepseek/message.rs delete mode 100644 src/core/chat/provider/deepseek/mod.rs delete mode 100644 src/core/chat/provider/deepseek/test.rs delete mode 100644 src/core/chat/provider/deepseek/tool.rs rename src/core/{chat/provider/anthropic => config}/mod.rs (100%) rename src/core/{chat/provider/common => errors}/mod.rs (100%) create mode 100644 src/core/providers/anthropic/mod.rs rename src/core/{chat/client.rs => providers/anthropic/model.rs} (100%) rename src/core/{chat/provider/deepseek/chat.rs => providers/basic/completions.rs} (55%) rename src/core/{chat/provider/openai/mod.rs => providers/basic/messages.rs} (100%) create mode 100644 src/core/providers/basic/mod.rs rename src/core/{context/mod.rs => providers/basic/responses.rs} (100%) create mode 100644 src/core/providers/deepseek/mod.rs rename src/core/{chat/provider => providers}/deepseek/model.rs (100%) rename src/core/{chat/provider => providers}/mod.rs (51%) create mode 100644 src/core/providers/openai/mod.rs rename src/core/{event/mod.rs => providers/openai/model.rs} (100%) create mode 100644 src/core/providers/openai/request.rs rename src/core/{update => runtime}/mod.rs (100%) create mode 100644 src/core/tools/mod.rs create mode 100644 src/core/transport/client.rs create mode 100644 src/core/transport/mod.rs diff --git a/Cargo.lock b/Cargo.lock index cc5cdae..090501c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,7 @@ dependencies = [ "log", "md5", "once_cell", + "rand 0.10.1", "ratatui 0.30.0", "ratatui-kit", "reqwest", @@ -342,6 +343,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -1116,6 +1128,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", "wasm-bindgen", @@ -1840,6 +1853,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2286,6 +2309,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2311,6 +2345,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.29.0" @@ -2526,6 +2566,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -2534,6 +2575,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -2943,6 +2985,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3624,6 +3678,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 3eab6d6..b1c3450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ anyhow = "1.0.102" tokio = { version = "1.52.3", features = ["full"] } colored = "3.1.1" indicatif = "0.18.4" -reqwest = { version = "0.13.3", features = ["stream", "json"] } +reqwest = { version = "0.13.3", features = ["stream", "json", "multipart", "query"] } tokio-stream = "0.1.18" futures = "0.3.32" futures-util = "0.3.32" @@ -40,4 +40,5 @@ tar = "0.4.46" thiserror = "2.0.18" hex = "0.4.3" dotenv = "0.15.0" -bytes = "1.11.1" \ No newline at end of file +bytes = "1.11.1" +rand = "0.10.1" \ No newline at end of file diff --git a/src/core/action/mod.rs b/src/core/agent/mod.rs similarity index 100% rename from src/core/action/mod.rs rename to src/core/agent/mod.rs diff --git a/src/core/chat/mod.rs b/src/core/chat/mod.rs deleted file mode 100644 index 0278328..0000000 --- a/src/core/chat/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod client; -mod provider; \ No newline at end of file diff --git a/src/core/chat/provider/deepseek/enums.rs b/src/core/chat/provider/deepseek/enums.rs deleted file mode 100644 index e241678..0000000 --- a/src/core/chat/provider/deepseek/enums.rs +++ /dev/null @@ -1,38 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum Role { - System, - User, - Assistant, - Tool, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ToolType { - Function, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ResponseFormatType { - Text, - JsonObject, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum FinishReason { - Stop, - Length, - ToolCalls, - ContentFilter, -} - -impl Default for ResponseFormatType { - fn default() -> Self { - Self::Text - } -} \ No newline at end of file diff --git a/src/core/chat/provider/deepseek/message.rs b/src/core/chat/provider/deepseek/message.rs deleted file mode 100644 index d50b93a..0000000 --- a/src/core/chat/provider/deepseek/message.rs +++ /dev/null @@ -1,47 +0,0 @@ -use serde::{Deserialize, Serialize}; -use super::enums::Role; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum MessageContent { - Text(String), - Parts(Vec), -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ContentPart { - pub r#type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub image_url: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ImageUrl { - pub url: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ChatMessage { - pub role: Role, - pub content: MessageContent, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, -} - -impl Default for ChatMessage { - fn default() -> Self { - Self { - role: Role::User, - content: MessageContent::Text(String::new()), - name: None, - tool_call_id: None, - tool_calls: None, - } - } -} \ No newline at end of file diff --git a/src/core/chat/provider/deepseek/mod.rs b/src/core/chat/provider/deepseek/mod.rs deleted file mode 100644 index 46abd49..0000000 --- a/src/core/chat/provider/deepseek/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod chat; -mod model; -mod enums; -mod tool; -mod message; -mod test; \ No newline at end of file diff --git a/src/core/chat/provider/deepseek/test.rs b/src/core/chat/provider/deepseek/test.rs deleted file mode 100644 index 3456c68..0000000 --- a/src/core/chat/provider/deepseek/test.rs +++ /dev/null @@ -1,281 +0,0 @@ -// 修复:删除重复导入 -use crate::core::chat::provider::deepseek::{model::*, enums::*, message::*, tool::*, chat::*}; -use reqwest::Client; -use std::env; -use dotenv::dotenv; -use serde_json; - -const API_BASE: &str = "https://api.deepseek.com"; - -fn main() {} - -#[cfg(test)] -mod tests { - use super::*; - use futures_util::StreamExt; - - /// 获取 API Key(加载 .env 文件) - fn get_api_key() -> String { - dotenv().ok(); - env::var("API_KEY").expect("❌ 请在 .env 文件中设置 API_KEY 环境变量") - } - - // 1. 测试:获取模型列表 ✅ 修复:移除 /v1 前缀 - #[tokio::test] - async fn test_list_models() -> anyhow::Result<()> { - let client = Client::new(); - let auth = format!("Bearer {}", get_api_key()); - - let resp: ListModelsResponse = client - .get(format!("{API_BASE}/models")) // 修复点1 - .header("Authorization", &auth) - .send() - .await? - .json() - .await?; - - assert_eq!(resp.object, "list"); - assert!(!resp.data.is_empty()); - - println!("\n====================================="); - println!("📋 模型列表测试结果"); - println!("====================================="); - println!("{}", serde_json::to_string_pretty(&resp)?); - println!("✅ 模型列表测试通过,共 {} 个模型", resp.data.len()); - - Ok(()) - } - - // 2. 测试:普通对话 ✅ 修复:MessageContent 取值 - #[tokio::test] - async fn test_chat_normal() -> anyhow::Result<()> { - let client = Client::new(); - let auth = format!("Bearer {}", get_api_key()); - - let req = CreateChatCompletionRequest { - model: "deepseek-v4-flash".into(), - messages: vec![ChatMessage { - role: Role::User, - content: MessageContent::Text("你好".into()), - ..Default::default() - }], - max_tokens: Some(100), - ..Default::default() - }; - - let resp: ChatCompletionResponse = client - .post(format!("{API_BASE}/v1/chat/completions")) - .header("Authorization", &auth) - .json(&req) - .send() - .await? - .json() - .await?; - - assert!(!resp.choices.is_empty()); - assert_eq!(resp.model, "deepseek-v4-flash"); - - // 修复点2:直接获取 MessageContent 文本 - let reply = match &resp.choices[0].message.content { - MessageContent::Text(text) => text, - _ => "非文本消息", - }; - - println!("\n====================================="); - println!("💬 普通对话测试结果"); - println!("====================================="); - println!("AI 回复:{}", reply); - println!("完整响应:{}", serde_json::to_string_pretty(&resp)?); - println!("✅ 普通对话测试通过"); - - Ok(()) - } - - // 3. 测试:深度思考模型 ✅ 修复:删除不存在的 reasoning_content 字段 - #[tokio::test] - async fn test_chat_reasoner() -> anyhow::Result<()> { - let client = Client::new(); - let auth = format!("Bearer {}", get_api_key()); - - let req = CreateChatCompletionRequest { - model: "deepseek-reasoner".into(), - messages: vec![ChatMessage { - role: Role::User, - content: MessageContent::Text("1024*1024等于多少?".into()), - ..Default::default() - }], - ..Default::default() - }; - - let resp: ChatCompletionResponse = client - .post(format!("{API_BASE}/v1/chat/completions")) - .header("Authorization", &auth) - .json(&req) - .send() - .await? - .json() - .await?; - - assert!(!resp.choices.is_empty()); - - // 修复点3:移除无此字段的打印 - let reply = match &resp.choices[0].message.content { - MessageContent::Text(text) => text, - _ => "非文本消息", - }; - - println!("\n====================================="); - println!("🤯 深度思考模型测试结果"); - println!("====================================="); - println!("最终回复:{}", reply); - println!("完整响应:{}", serde_json::to_string_pretty(&resp)?); - println!("✅ 思考模型测试通过"); - - Ok(()) - } - - // 4. 测试:JSON 格式输出 ✅ 修复:MessageContent 取值 - #[tokio::test] - async fn test_chat_json_format() -> anyhow::Result<()> { - let client = Client::new(); - let auth = format!("Bearer {}", get_api_key()); - - let req = CreateChatCompletionRequest { - model: "deepseek-v4-flash".into(), - messages: vec![ChatMessage { - role: Role::User, - content: MessageContent::Text("返回一个包含name和age的JSON".into()), - ..Default::default() - }], - response_format: Some(ResponseFormat::default()), - ..Default::default() - }; - - let resp: ChatCompletionResponse = client - .post(format!("{API_BASE}/v1/chat/completions")) - .header("Authorization", &auth) - .json(&req) - .send() - .await? - .json() - .await?; - - assert!(!resp.choices.is_empty()); - - let json_result = match &resp.choices[0].message.content { - MessageContent::Text(text) => text, - _ => "非文本消息", - }; - - println!("\n====================================="); - println!("📄 JSON 格式输出测试结果"); - println!("====================================="); - println!("JSON 内容:{}", json_result); - println!("完整响应:{}", serde_json::to_string_pretty(&resp)?); - println!("✅ JSON输出测试通过"); - - Ok(()) - } - - // 5. 测试:工具调用 ✅ 无报错,保持原样 - #[tokio::test] - async fn test_chat_tool_call() -> anyhow::Result<()> { - let client = Client::new(); - let auth = format!("Bearer {}", get_api_key()); - - let req = CreateChatCompletionRequest { - model: "deepseek-v4-flash".into(), - messages: vec![ChatMessage { - role: Role::User, - content: MessageContent::Text("查询上海天气".into()), - ..Default::default() - }], - tools: Some(vec![Tool { - r#type: ToolType::Function, - function: FunctionObject { - name: "get_weather".into(), - description: Some("获取城市天气".into()), - parameters: Some(serde_json::to_value( - serde_json::json!({ - "type": "object", - "properties": { - "city": {"type": "string"} - }, - "required": ["city"] - }) - )?), - }, - }]), - tool_choice: Some(ToolChoice::Strategy("auto".into())), - ..Default::default() - }; - - let resp: ChatCompletionResponse = client - .post(format!("{API_BASE}/v1/chat/completions")) - .header("Authorization", &auth) - .json(&req) - .send() - .await? - .json() - .await?; - - assert!(resp.choices[0].message.tool_calls.is_some()); - - let tool_call = &resp.choices[0].message.tool_calls.as_ref().unwrap()[0]; - println!("\n====================================="); - println!("🔧 工具调用测试结果"); - println!("====================================="); - println!("调用函数:{}", tool_call.function.name); - println!("调用参数:{}", tool_call.function.arguments); - println!("完整响应:{}", serde_json::to_string_pretty(&resp)?); - println!("✅ 工具调用测试通过"); - - Ok(()) - } - - // 6. 测试:流式输出 ✅ 无报错,保持原样 - #[tokio::test] - async fn test_chat_stream() -> anyhow::Result<()> { - let client = Client::new(); - let auth = format!("Bearer {}", get_api_key()); - - let req = CreateChatCompletionRequest { - model: "deepseek-v4-flash".into(), - messages: vec![ChatMessage { - role: Role::User, - content: MessageContent::Text("介绍Rust".into()), - ..Default::default() - }], - stream: Some(true), - stream_options: Some(StreamOptions { - include_usage: Some(true), - }), - ..Default::default() - }; - - let mut stream = client - .post(format!("{API_BASE}/v1/chat/completions")) - .header("Authorization", &auth) - .json(&req) - .send() - .await? - .bytes_stream(); - - println!("\n====================================="); - println!("🌊 流式输出测试结果(实时打印)"); - println!("====================================="); - - let mut full_content = String::new(); - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result?; - let s = String::from_utf8_lossy(&chunk); - print!("{}", s); - full_content.push_str(&s); - } - - assert!(!full_content.is_empty()); - println!("\n✅ 流式输出测试通过"); - - Ok(()) - } -} \ No newline at end of file diff --git a/src/core/chat/provider/deepseek/tool.rs b/src/core/chat/provider/deepseek/tool.rs deleted file mode 100644 index 2bc3c1a..0000000 --- a/src/core/chat/provider/deepseek/tool.rs +++ /dev/null @@ -1,49 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use super::enums::ToolType; - -#[derive(Debug, Serialize, Deserialize)] -pub struct Tool { - pub r#type: ToolType, - pub function: FunctionObject, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct FunctionObject { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub parameters: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - pub r#type: ToolType, - pub function: ToolCallFunction, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolCallFunction { - pub name: String, - pub arguments: String, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ToolChoice { - Strategy(String), - Tool(ToolChoiceObject), -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolChoiceObject { - pub r#type: ToolType, - pub function: ToolChoiceFunction, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolChoiceFunction { - pub name: String, -} \ No newline at end of file diff --git a/src/core/chat/provider/anthropic/mod.rs b/src/core/config/mod.rs similarity index 100% rename from src/core/chat/provider/anthropic/mod.rs rename to src/core/config/mod.rs diff --git a/src/core/db/mod.rs b/src/core/db/mod.rs index 98139ab..9099022 100644 --- a/src/core/db/mod.rs +++ b/src/core/db/mod.rs @@ -316,7 +316,7 @@ impl DatabaseManager { Ok(result) } - /// update + /// config pub fn update(&self, model: T) -> Result where T: Model, diff --git a/src/core/chat/provider/common/mod.rs b/src/core/errors/mod.rs similarity index 100% rename from src/core/chat/provider/common/mod.rs rename to src/core/errors/mod.rs diff --git a/src/core/mod.rs b/src/core/mod.rs index 6ec46a2..1807b9b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,6 +1,8 @@ -pub mod action; -pub mod event; -pub mod update; -pub mod context; +pub mod transport; +pub mod tools; +pub mod config; +pub mod runtime; pub mod db; -pub mod chat; \ No newline at end of file +pub mod agent; +mod providers; +mod errors; \ No newline at end of file diff --git a/src/core/providers/anthropic/mod.rs b/src/core/providers/anthropic/mod.rs new file mode 100644 index 0000000..08c3d84 --- /dev/null +++ b/src/core/providers/anthropic/mod.rs @@ -0,0 +1,21 @@ +use crate::core::providers::basic::InterfaceFormat; + +mod model; + +pub struct Anthropic { + base_url: String, + anthropic_version: String, + interface_format: InterfaceFormat, + pub x_api_key: String, +} + +impl Default for Anthropic { + fn default() -> Self { + Anthropic { + base_url: "https://api.anthropic.com".to_string(), + anthropic_version: "2023-06-01".to_string(), + interface_format: InterfaceFormat::Messages, + x_api_key: "sk-ant-...".to_string() + } + } +} \ No newline at end of file diff --git a/src/core/chat/client.rs b/src/core/providers/anthropic/model.rs similarity index 100% rename from src/core/chat/client.rs rename to src/core/providers/anthropic/model.rs diff --git a/src/core/chat/provider/deepseek/chat.rs b/src/core/providers/basic/completions.rs similarity index 55% rename from src/core/chat/provider/deepseek/chat.rs rename to src/core/providers/basic/completions.rs index 861260b..46c292c 100644 --- a/src/core/chat/provider/deepseek/chat.rs +++ b/src/core/providers/basic/completions.rs @@ -1,6 +1,42 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::{enums::*, message::ChatMessage, tool::*}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Role { + System, + User, + Assistant, + Tool, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ToolType { + Function, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ResponseFormatType { + Text, + JsonObject, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FinishReason { + Stop, + Length, + ToolCalls, + ContentFilter, +} + +impl Default for ResponseFormatType { + fn default() -> Self { + Self::Text + } +} #[derive(Debug, Serialize, Deserialize, Default)] pub struct StreamOptions { @@ -114,4 +150,95 @@ pub struct PromptTokensDetails { #[derive(Debug, Serialize, Deserialize)] pub struct CompletionTokensDetails { pub reasoning_tokens: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MessageContent { + Text(String), + Parts(Vec), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ContentPart { + pub r#type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ImageUrl { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: Role, + pub content: MessageContent, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, +} + +impl Default for ChatMessage { + fn default() -> Self { + Self { + role: Role::User, + content: MessageContent::Text(String::new()), + name: None, + tool_call_id: None, + tool_calls: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Tool { + pub r#type: ToolType, + pub function: FunctionObject, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FunctionObject { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + pub r#type: ToolType, + pub function: ToolCallFunction, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolCallFunction { + pub name: String, + pub arguments: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ToolChoice { + Strategy(String), + Tool(ToolChoiceObject), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolChoiceObject { + pub r#type: ToolType, + pub function: ToolChoiceFunction, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolChoiceFunction { + pub name: String, } \ No newline at end of file diff --git a/src/core/chat/provider/openai/mod.rs b/src/core/providers/basic/messages.rs similarity index 100% rename from src/core/chat/provider/openai/mod.rs rename to src/core/providers/basic/messages.rs diff --git a/src/core/providers/basic/mod.rs b/src/core/providers/basic/mod.rs new file mode 100644 index 0000000..0328a11 --- /dev/null +++ b/src/core/providers/basic/mod.rs @@ -0,0 +1,9 @@ +mod completions; +mod responses; +mod messages; + +pub enum InterfaceFormat { + Completions, + Responses, + Messages +} \ No newline at end of file diff --git a/src/core/context/mod.rs b/src/core/providers/basic/responses.rs similarity index 100% rename from src/core/context/mod.rs rename to src/core/providers/basic/responses.rs diff --git a/src/core/providers/deepseek/mod.rs b/src/core/providers/deepseek/mod.rs new file mode 100644 index 0000000..15b74a0 --- /dev/null +++ b/src/core/providers/deepseek/mod.rs @@ -0,0 +1,19 @@ +use crate::core::providers::basic::InterfaceFormat; + +mod model; + +pub struct Deepseek { + base_url: String, + interface_format: InterfaceFormat, + pub authorization: String, +} + +impl Default for Deepseek { + fn default() -> Self { + Deepseek { + base_url: "https://api.deepseek.com".to_string(), + interface_format: InterfaceFormat::Completions, + authorization: "sk-...".to_string() + } + } +} \ No newline at end of file diff --git a/src/core/chat/provider/deepseek/model.rs b/src/core/providers/deepseek/model.rs similarity index 100% rename from src/core/chat/provider/deepseek/model.rs rename to src/core/providers/deepseek/model.rs diff --git a/src/core/chat/provider/mod.rs b/src/core/providers/mod.rs similarity index 51% rename from src/core/chat/provider/mod.rs rename to src/core/providers/mod.rs index 556dda3..6312b2c 100644 --- a/src/core/chat/provider/mod.rs +++ b/src/core/providers/mod.rs @@ -1,4 +1,4 @@ mod anthropic; -mod common; mod openai; -mod deepseek; \ No newline at end of file +mod deepseek; +mod basic; \ No newline at end of file diff --git a/src/core/providers/openai/mod.rs b/src/core/providers/openai/mod.rs new file mode 100644 index 0000000..e457d83 --- /dev/null +++ b/src/core/providers/openai/mod.rs @@ -0,0 +1,20 @@ +use crate::core::providers::basic::InterfaceFormat; + +mod request; +mod model; + +pub struct OpenAI { + base_url: String, + interface_format: InterfaceFormat, + pub authorization: String, +} + +impl Default for OpenAI { + fn default() -> Self { + OpenAI { + base_url: "https://api.openai.com".to_string(), + interface_format: InterfaceFormat::Responses, + authorization: "sk-...".to_string() + } + } +} \ No newline at end of file diff --git a/src/core/event/mod.rs b/src/core/providers/openai/model.rs similarity index 100% rename from src/core/event/mod.rs rename to src/core/providers/openai/model.rs diff --git a/src/core/providers/openai/request.rs b/src/core/providers/openai/request.rs new file mode 100644 index 0000000..02fd852 --- /dev/null +++ b/src/core/providers/openai/request.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Request { + +} \ No newline at end of file diff --git a/src/core/update/mod.rs b/src/core/runtime/mod.rs similarity index 100% rename from src/core/update/mod.rs rename to src/core/runtime/mod.rs diff --git a/src/core/tools/mod.rs b/src/core/tools/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/core/transport/client.rs b/src/core/transport/client.rs new file mode 100644 index 0000000..e0c4eea --- /dev/null +++ b/src/core/transport/client.rs @@ -0,0 +1,654 @@ +use anyhow::{anyhow, Result}; +use bytes::Bytes; +use futures_util::stream::Stream; +use rand::{Rng, RngExt}; +use reqwest::{ + multipart, + Client, + Method, + Request, + RequestBuilder, + Response, + StatusCode, +}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::time::Duration; +use log::{info, warn}; +use tokio::fs; + +// ============================================================ +// Retry Config +// ============================================================ + +#[derive(Debug, Clone)] +pub struct RetryConfig { + pub max_retries: u32, + + pub retry_interval: u64, + + pub use_exponential_backoff: bool, + + pub backoff_factor: f64, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_retries: 3, + retry_interval: 1000, + use_exponential_backoff: true, + backoff_factor: 2.0, + } + } +} + +// ============================================================ +// Request Context +// ============================================================ + +#[derive(Debug, Clone)] +pub struct RequestContext { + pub method: Method, + pub url: String, +} + +// ============================================================ +// Request Result +// ============================================================ + +pub enum RequestResult { + Response(Response), + Error(reqwest::Error), +} + +// ============================================================ +// Retry Predicate +// ============================================================ + +pub type RetryPredicate = +Box< + dyn Fn(&RequestContext, &RequestResult) -> bool + + Send + + Sync + + 'static, +>; + +// ============================================================ +// Request Factory +// ============================================================ + +type BoxFuture = +Pin + Send>>; + +pub type RequestFactory = +Box< + dyn Fn() -> BoxFuture> + + Send + + Sync, +>; + +// ============================================================ +// Retry Middleware +// ============================================================ + +pub struct RetryMiddleware { + pub config: RetryConfig, + + pub predicate: Option, +} + +impl Default for RetryMiddleware { + fn default() -> Self { + Self { + config: RetryConfig::default(), + predicate: None, + } + } +} + +impl RetryMiddleware { + pub fn new(config: RetryConfig) -> Self { + Self { + config, + predicate: None, + } + } + + pub fn with_retry_predicate( + mut self, + f: F, + ) -> Self + where + F: Fn(&RequestContext, &RequestResult) -> bool + + Send + + Sync + + 'static, + { + self.predicate = Some(Box::new(f)); + + self + } + + // ======================================================== + // Full Jitter Backoff + // ======================================================== + + fn calculate_wait_time( + &self, + attempts: u32, + ) -> Duration { + let base = self.config.retry_interval as f64; + + let max_wait = if self + .config + .use_exponential_backoff + { + base + * self + .config + .backoff_factor + .powi(attempts as i32) + } else { + base + }; + + let mut rng = rand::rng(); + + let wait_ms = + rng.random_range(0.0..=max_wait); + + Duration::from_millis(wait_ms as u64) + } + + // ======================================================== + // Retry-After + // ======================================================== + + fn retry_after( + response: &Response, + ) -> Option { + let header = response + .headers() + .get("retry-after")?; + + let value = header.to_str().ok()?; + + let secs = value.parse::().ok()?; + + Some(Duration::from_secs(secs)) + } + + // ======================================================== + // Default Retry Rule + // ======================================================== + + fn should_retry( + &self, + ctx: &RequestContext, + result: &RequestResult, + ) -> bool { + if let Some(predicate) = &self.predicate { + return predicate(ctx, result); + } + + // 默认只 retry 幂等请求 + let retryable_method = matches!( + ctx.method, + Method::GET + | Method::HEAD + | Method::PUT + | Method::DELETE + ); + + if !retryable_method { + return false; + } + + match result { + RequestResult::Response(resp) => { + resp.status().is_server_error() + || resp.status() + == StatusCode::TOO_MANY_REQUESTS + } + + RequestResult::Error(err) => { + err.is_timeout() + || err.is_connect() + || err + .status() + .map(|s| s.is_server_error()) + .unwrap_or(false) + } + } + } + + // ======================================================== + // Execute + // ======================================================== + + pub async fn execute( + &self, + client: &Client, + factory: RequestFactory, + ) -> Result { + let mut attempt = 0; + + loop { + let (ctx, request) = factory().await?; + + info!( + method = %ctx.method, + url = %ctx.url, + attempt, + "sending request" + ); + + let result = + match client.execute(request).await { + Ok(resp) => { + RequestResult::Response(resp) + } + + Err(err) => { + RequestResult::Error(err) + } + }; + + let should_retry = + self.should_retry(&ctx, &result); + + if !should_retry + || attempt >= self.config.max_retries + { + return match result { + RequestResult::Response(resp) => { + Ok(resp) + } + + RequestResult::Error(err) => { + Err(anyhow!(err)) + } + }; + } + + attempt += 1; + + let wait = match &result { + RequestResult::Response(resp) => { + Self::retry_after(resp) + .unwrap_or_else(|| { + self.calculate_wait_time( + attempt, + ) + }) + } + + _ => self.calculate_wait_time(attempt), + }; + + warn!( + method = %ctx.method, + url = %ctx.url, + attempt, + wait_ms = wait.as_millis(), + "retrying request" + ); + + tokio::time::sleep(wait).await; + } + } +} + +// ============================================================ +// Http Client +// ============================================================ + +pub struct HttpClient { + client: Client, + + retry_middleware: RetryMiddleware, +} + +impl HttpClient { + pub fn new( + retry_config: Option, + ) -> Result { + let client = Client::builder() + .connect_timeout(Duration::from_secs( + 10, + )) + .timeout(Duration::from_secs(300)) + .pool_idle_timeout(Duration::from_secs( + 90, + )) + .tcp_keepalive(Duration::from_secs(60)) + .build()?; + + Ok(Self { + client, + + retry_middleware: retry_config + .map(RetryMiddleware::new) + .unwrap_or_default(), + }) + } + + // ======================================================== + // Core Send + // ======================================================== + + pub async fn send( + &self, + builder: RequestBuilder, + ) -> Result { + let builder = builder + .try_clone() + .ok_or_else(|| { + anyhow!( + "request builder cannot be cloned" + ) + })?; + + let factory: RequestFactory = + Box::new(move || { + let builder = builder + .try_clone() + .ok_or_else(|| { + anyhow!( + "request builder clone failed" + ) + }); + + Box::pin(async move { + let builder = builder?; + + let request = builder.build()?; + + let ctx = RequestContext { + method: request.method().clone(), + + url: request.url().to_string(), + }; + + Ok((ctx, request)) + }) + }); + + self.retry_middleware + .execute(&self.client, factory) + .await + } + + // ======================================================== + // GET JSON + // ======================================================== + + pub async fn get_json( + &self, + url: &str, + query: &impl Serialize, + ) -> Result + where + T: DeserializeOwned, + { + let response = self + .send(self.client.get(url).query(query)) + .await?; + + Ok(response.json().await?) + } + + // ======================================================== + // POST JSON + // ======================================================== + + pub async fn post_json( + &self, + url: &str, + body: &impl Serialize, + ) -> Result + where + T: DeserializeOwned, + { + let response = self + .send(self.client.post(url).json(body)) + .await?; + + Ok(response.json().await?) + } + + // ======================================================== + // GET TEXT + // ======================================================== + + pub async fn get_text( + &self, + url: &str, + ) -> Result { + let response = self + .send(self.client.get(url)) + .await?; + + Ok(response.text().await?) + } + + // ======================================================== + // GET BYTES + // ======================================================== + + pub async fn get_bytes( + &self, + url: &str, + ) -> Result { + let response = self + .send(self.client.get(url)) + .await?; + + Ok(response.bytes().await?) + } + + // ======================================================== + // SSE / STREAM + // ======================================================== + + pub async fn get_stream( + &self, + url: &str, + ) -> Result< + impl Stream< + Item = Result, + >, + > { + let response = self + .send(self.client.get(url)) + .await?; + + Ok(response.bytes_stream()) + } + + // ======================================================== + // Multipart Upload + // ======================================================== + + pub async fn upload_file( + &self, + url: &str, + field_name: &str, + file_path: impl Into, + ) -> Result + where + T: DeserializeOwned, + { + let path = file_path.into(); + + let client = self.client.clone(); + + let url = url.to_string(); + + let field_name = field_name.to_string(); + + let factory: RequestFactory = + Box::new(move || { + let client = client.clone(); + + let path = path.clone(); + + let url = url.clone(); + + let field_name = + field_name.clone(); + + Box::pin(async move { + let bytes = + fs::read(&path).await?; + + let filename = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let part = + multipart::Part::bytes( + bytes, + ) + .file_name(filename); + + let form = + multipart::Form::new() + .part( + field_name, + part, + ); + + let request = client + .post(url) + .multipart(form) + .build()?; + + let ctx = RequestContext { + method: request + .method() + .clone(), + + url: request + .url() + .to_string(), + }; + + Ok((ctx, request)) + }) + }); + + let response = self + .retry_middleware + .execute(&self.client, factory) + .await?; + + Ok(response.json().await?) + } + + // ======================================================== + // Stream Upload + // ======================================================== + + pub async fn upload_stream( + &self, + url: &str, + stream_factory: impl Fn() -> S + + Send + + Sync + + 'static, + ) -> Result + where + T: DeserializeOwned, + + S: Stream< + Item = Result< + Bytes, + std::io::Error, + >, + > + Send + + 'static, + { + let client = self.client.clone(); + + let url = url.to_string(); + + let factory: RequestFactory = + Box::new(move || { + let client = client.clone(); + + let url = url.clone(); + + let stream = stream_factory(); + + Box::pin(async move { + let body = + reqwest::Body::wrap_stream( + stream, + ); + + let request = client + .post(url) + .body(body) + .build()?; + + let ctx = RequestContext { + method: request + .method() + .clone(), + + url: request + .url() + .to_string(), + }; + + Ok((ctx, request)) + }) + }); + + let response = self + .retry_middleware + .execute(&self.client, factory) + .await?; + + Ok(response.json().await?) + } + + // ======================================================== + // Retry Predicate + // ======================================================== + + pub fn with_retry_predicate( + mut self, + predicate: F, + ) -> Self + where + F: Fn( + &RequestContext, + &RequestResult, + ) -> bool + + Send + + Sync + + 'static, + { + self.retry_middleware = self + .retry_middleware + .with_retry_predicate(predicate); + + self + } + + // ======================================================== + // Inner Client + // ======================================================== + + pub fn inner(&self) -> &Client { + &self.client + } +} \ No newline at end of file diff --git a/src/core/transport/mod.rs b/src/core/transport/mod.rs new file mode 100644 index 0000000..2322d1e --- /dev/null +++ b/src/core/transport/mod.rs @@ -0,0 +1 @@ +mod client; \ No newline at end of file From 3a042e5bb71b04711da2a28898a0d1acb60a5bd3 Mon Sep 17 00:00:00 2001 From: Gabriel Bao Date: Wed, 27 May 2026 20:02:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(db):=20=E4=BF=AE=E5=A4=8DIDEA=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E5=90=8E=E7=9A=84=E9=94=99=E8=AF=AF=E6=9B=B4?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/db/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/db/mod.rs b/src/core/db/mod.rs index 9099022..98139ab 100644 --- a/src/core/db/mod.rs +++ b/src/core/db/mod.rs @@ -316,7 +316,7 @@ impl DatabaseManager { Ok(result) } - /// config + /// update pub fn update(&self, model: T) -> Result where T: Model,