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 {
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
163fn 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}