Skip to main content

lc_tools/
search.rs

1// lc-tools/src/search.rs
2//! DuckDuckGo 搜索工具
3//!
4//! 通过 DuckDuckGo Instant Answer API 进行网页搜索。
5//! 无需 API Key,免费使用。
6
7use async_trait::async_trait;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use lc_core::tools::{BaseTool, Tool, ToolError};
12
13/// 搜索工具输入
14#[derive(Debug, Deserialize, JsonSchema)]
15pub struct SearchInput {
16    /// 搜索查询
17    pub query: String,
18    /// 返回结果数量(默认 5)
19    pub top_k: Option<usize>,
20}
21
22/// 搜索工具输出
23#[derive(Debug, Serialize)]
24pub struct SearchOutput {
25    /// 查询
26    pub query: String,
27    /// 结果列表
28    pub results: Vec<SearchResult>,
29    /// 结果数量
30    pub total: usize,
31    /// 摘要(如有)
32    pub abstract_text: Option<String>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct SearchResult {
37    /// 标题
38    pub title: String,
39    /// 摘要
40    pub snippet: String,
41    /// URL
42    pub url: String,
43}
44
45/// DuckDuckGo 网页搜索工具
46///
47/// 使用 DuckDuckGo 的 Instant Answer API 进行搜索,无需 API Key。
48pub struct DuckDuckGoSearchTool {
49    client: reqwest::Client,
50}
51
52impl DuckDuckGoSearchTool {
53    pub fn new() -> Self {
54        Self {
55            client: reqwest::Client::builder()
56                .timeout(std::time::Duration::from_secs(15))
57                .user_agent("LangChainRust/0.1 (Search Tool)")
58                .build()
59                .unwrap_or_else(|_| reqwest::Client::new()),
60        }
61    }
62}
63
64impl Default for DuckDuckGoSearchTool {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70#[async_trait]
71impl Tool for DuckDuckGoSearchTool {
72    type Input = SearchInput;
73    type Output = SearchOutput;
74
75    async fn invoke(&self, input: Self::Input) -> Result<Self::Output, ToolError> {
76        if input.query.trim().is_empty() {
77            return Err(ToolError::InvalidInput("搜索查询不能为空".to_string()));
78        }
79
80        let top_k = input.top_k.unwrap_or(5);
81        let url = format!(
82            "https://api.duckduckgo.com/?q={}&format=json&no_html=1",
83            urlencoding(&input.query)
84        );
85
86        let response = self
87            .client
88            .get(&url)
89            .send()
90            .await
91            .map_err(|e| ToolError::ExecutionFailed(format!("搜索请求失败: {}", e)))?;
92
93        let body: serde_json::Value = response
94            .json()
95            .await
96            .map_err(|e| ToolError::ExecutionFailed(format!("解析搜索结果失败: {}", e)))?;
97
98        let abstract_text = body["AbstractText"]
99            .as_str()
100            .filter(|s| !s.is_empty())
101            .map(|s| s.to_string());
102
103        let mut results = Vec::new();
104        if let Some(topics) = body["RelatedTopics"].as_array() {
105            for topic in topics.iter() {
106                if results.len() >= top_k {
107                    break;
108                }
109                if let Some(text) = topic["Text"].as_str() {
110                    let title = topic["FirstURL"]
111                        .as_str()
112                        .map(|u| u.rsplit('/').next().unwrap_or(u).replace('_', " "))
113                        .unwrap_or_default();
114                    let url = topic["FirstURL"].as_str().unwrap_or("");
115                    results.push(SearchResult {
116                        title,
117                        snippet: text.to_string(),
118                        url: url.to_string(),
119                    });
120                }
121                if let Some(nested) = topic["Topics"].as_array() {
122                    for nt in nested.iter() {
123                        if results.len() >= top_k {
124                            break;
125                        }
126                        if let Some(text) = nt["Text"].as_str() {
127                            let title = nt["FirstURL"]
128                                .as_str()
129                                .map(|u| u.rsplit('/').next().unwrap_or(u).replace('_', " "))
130                                .unwrap_or_default();
131                            let url = nt["FirstURL"].as_str().unwrap_or("");
132                            results.push(SearchResult {
133                                title,
134                                snippet: text.to_string(),
135                                url: url.to_string(),
136                            });
137                        }
138                    }
139                }
140            }
141        }
142
143        if results.is_empty() {
144            if let Some(ref abstract_text) = abstract_text {
145                let source_url = body["AbstractURL"].as_str().unwrap_or("");
146                results.push(SearchResult {
147                    title: input.query.clone(),
148                    snippet: abstract_text.clone(),
149                    url: source_url.to_string(),
150                });
151            }
152        }
153
154        Ok(SearchOutput {
155            query: input.query,
156            total: results.len(),
157            results,
158            abstract_text,
159        })
160    }
161}
162
163/// URL 编码 (using urlencoding crate for complete encoding)
164fn urlencoding(s: &str) -> String {
165    urlencoding::encode(s).to_string()
166}
167
168#[async_trait]
169impl BaseTool for DuckDuckGoSearchTool {
170    fn name(&self) -> &str {
171        "web_search"
172    }
173
174    fn description(&self) -> &str {
175        "网页搜索工具。使用 DuckDuckGo 搜索引擎搜索网络信息,无需 API Key。
176
177参数:
178- query: 搜索关键词
179- top_k: 返回结果数量(默认 5)
180
181示例:
182- {\"query\": \"Rust programming language\", \"top_k\": 3}"
183    }
184
185    async fn run(&self, input: String) -> Result<String, ToolError> {
186        let parsed: SearchInput = serde_json::from_str(&input)
187            .map_err(|e| ToolError::InvalidInput(format!("JSON 解析失败: {}", e)))?;
188
189        let output = self.invoke(parsed).await?;
190
191        let mut text = format!("搜索结果 (查询: {})\n\n", output.query);
192
193        if let Some(ref abstract_text) = output.abstract_text {
194            text.push_str(&format!("摘要: {}\n\n", abstract_text));
195        }
196
197        for (i, result) in output.results.iter().enumerate() {
198            text.push_str(&format!("{}. {}\n", i + 1, result.title));
199            text.push_str(&format!("   {}\n", result.snippet));
200            text.push_str(&format!("   URL: {}\n\n", result.url));
201        }
202
203        if output.results.is_empty() {
204            text.push_str("未找到相关结果");
205        } else {
206            text.push_str(&format!("共 {} 条结果", output.total));
207        }
208
209        Ok(text)
210    }
211
212    fn args_schema(&self) -> Option<serde_json::Value> {
213        use schemars::schema_for;
214        serde_json::to_value(schema_for!(SearchInput)).ok()
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_search_tool_properties() {
224        let tool = DuckDuckGoSearchTool::new();
225        assert_eq!(tool.name(), "web_search");
226        assert!(tool.description().contains("DuckDuckGo"));
227        assert!(BaseTool::args_schema(&tool).is_some());
228    }
229
230    #[tokio::test]
231    async fn test_search_empty_query() {
232        let tool = DuckDuckGoSearchTool::new();
233        let result = tool.run(r#"{"query": ""}"#.to_string()).await;
234        assert!(result.is_err());
235    }
236}