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    /// 创建 DuckDuckGo 搜索工具。
54    pub fn new() -> Self {
55        Self {
56            client: reqwest::Client::builder()
57                .timeout(std::time::Duration::from_secs(15))
58                .user_agent("LangChainRust/0.1 (Search Tool)")
59                .build()
60                .unwrap_or_else(|_| reqwest::Client::new()),
61        }
62    }
63}
64
65impl Default for DuckDuckGoSearchTool {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71#[async_trait]
72impl Tool for DuckDuckGoSearchTool {
73    type Input = SearchInput;
74    type Output = SearchOutput;
75
76    async fn invoke(&self, input: Self::Input) -> Result<Self::Output, ToolError> {
77        if input.query.trim().is_empty() {
78            return Err(ToolError::InvalidInput(
79                "search query must not be empty".to_string(),
80            ));
81        }
82
83        let top_k = input.top_k.unwrap_or(5);
84        let url = format!(
85            "https://api.duckduckgo.com/?q={}&format=json&no_html=1",
86            urlencoding(&input.query)
87        );
88
89        let response = self
90            .client
91            .get(&url)
92            .send()
93            .await
94            .map_err(|e| ToolError::ExecutionFailed(format!("search request failed: {}", e)))?;
95
96        let body: serde_json::Value = response.json().await.map_err(|e| {
97            ToolError::ExecutionFailed(format!("failed to parse search results: {}", e))
98        })?;
99
100        let abstract_text = body["AbstractText"]
101            .as_str()
102            .filter(|s| !s.is_empty())
103            .map(|s| s.to_string());
104
105        let mut results = Vec::new();
106        if let Some(topics) = body["RelatedTopics"].as_array() {
107            for topic in topics.iter() {
108                if results.len() >= top_k {
109                    break;
110                }
111                if let Some(text) = topic["Text"].as_str() {
112                    let title = topic["FirstURL"]
113                        .as_str()
114                        .map(|u| u.rsplit('/').next().unwrap_or(u).replace('_', " "))
115                        .unwrap_or_default();
116                    let url = topic["FirstURL"].as_str().unwrap_or("");
117                    results.push(SearchResult {
118                        title,
119                        snippet: text.to_string(),
120                        url: url.to_string(),
121                    });
122                }
123                if let Some(nested) = topic["Topics"].as_array() {
124                    for nt in nested.iter() {
125                        if results.len() >= top_k {
126                            break;
127                        }
128                        if let Some(text) = nt["Text"].as_str() {
129                            let title = nt["FirstURL"]
130                                .as_str()
131                                .map(|u| u.rsplit('/').next().unwrap_or(u).replace('_', " "))
132                                .unwrap_or_default();
133                            let url = nt["FirstURL"].as_str().unwrap_or("");
134                            results.push(SearchResult {
135                                title,
136                                snippet: text.to_string(),
137                                url: url.to_string(),
138                            });
139                        }
140                    }
141                }
142            }
143        }
144
145        if results.is_empty() {
146            if let Some(ref abstract_text) = abstract_text {
147                let source_url = body["AbstractURL"].as_str().unwrap_or("");
148                results.push(SearchResult {
149                    title: input.query.clone(),
150                    snippet: abstract_text.clone(),
151                    url: source_url.to_string(),
152                });
153            }
154        }
155
156        Ok(SearchOutput {
157            query: input.query,
158            total: results.len(),
159            results,
160            abstract_text,
161        })
162    }
163}
164
165/// URL 编码 (using urlencoding crate for complete encoding)
166fn urlencoding(s: &str) -> String {
167    urlencoding::encode(s).to_string()
168}
169
170#[async_trait]
171impl BaseTool for DuckDuckGoSearchTool {
172    fn name(&self) -> &str {
173        "web_search"
174    }
175
176    fn description(&self) -> &str {
177        "网页搜索工具。使用 DuckDuckGo 搜索引擎搜索网络信息,无需 API Key。
178
179参数:
180- query: 搜索关键词
181- top_k: 返回结果数量(默认 5)
182
183示例:
184- {\"query\": \"Rust programming language\", \"top_k\": 3}"
185    }
186
187    async fn run(&self, input: String) -> Result<String, ToolError> {
188        let parsed: SearchInput = serde_json::from_str(&input)
189            .map_err(|e| ToolError::InvalidInput(format!("JSON parse failed: {}", e)))?;
190
191        let output = self.invoke(parsed).await?;
192
193        let mut text = format!("搜索结果 (查询: {})\n\n", output.query);
194
195        if let Some(ref abstract_text) = output.abstract_text {
196            text.push_str(&format!("摘要: {}\n\n", abstract_text));
197        }
198
199        for (i, result) in output.results.iter().enumerate() {
200            text.push_str(&format!("{}. {}\n", i + 1, result.title));
201            text.push_str(&format!("   {}\n", result.snippet));
202            text.push_str(&format!("   URL: {}\n\n", result.url));
203        }
204
205        if output.results.is_empty() {
206            text.push_str("未找到相关结果");
207        } else {
208            text.push_str(&format!("共 {} 条结果", output.total));
209        }
210
211        Ok(text)
212    }
213
214    fn args_schema(&self) -> Option<serde_json::Value> {
215        use schemars::schema_for;
216        serde_json::to_value(schema_for!(SearchInput)).ok()
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_search_tool_properties() {
226        let tool = DuckDuckGoSearchTool::new();
227        assert_eq!(tool.name(), "web_search");
228        assert!(tool.description().contains("DuckDuckGo"));
229        assert!(BaseTool::args_schema(&tool).is_some());
230    }
231
232    #[tokio::test]
233    async fn test_search_empty_query() {
234        let tool = DuckDuckGoSearchTool::new();
235        let result = tool.run(r#"{"query": ""}"#.to_string()).await;
236        assert!(result.is_err());
237    }
238}