1use async_trait::async_trait;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use lc_core::tools::{BaseTool, Tool, ToolError};
12
13#[derive(Debug, Deserialize, JsonSchema)]
15pub struct SearchInput {
16 pub query: String,
18 pub top_k: Option<usize>,
20}
21
22#[derive(Debug, Serialize)]
24pub struct SearchOutput {
25 pub query: String,
27 pub results: Vec<SearchResult>,
29 pub total: usize,
31 pub abstract_text: Option<String>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct SearchResult {
37 pub title: String,
39 pub snippet: String,
41 pub url: String,
43}
44
45pub struct DuckDuckGoSearchTool {
49 client: reqwest::Client,
50}
51
52impl DuckDuckGoSearchTool {
53 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
165fn 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}