From 656e2734ce722881ad41cc17b2019fbdb737c1b6 Mon Sep 17 00:00:00 2001 From: beann <1012252+beann@user.noreply.gitee.com> Date: Mon, 30 Mar 2026 18:04:39 +0800 Subject: [PATCH] feat: add SearXNG search provider with custom URL and JSON output --- crates/openfang-runtime/src/web_search.rs | 105 +++++++++++++++++++++- crates/openfang-types/src/config.rs | 33 ++++++- 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/crates/openfang-runtime/src/web_search.rs b/crates/openfang-runtime/src/web_search.rs index 66fa2fdf..eb7dd130 100644 --- a/crates/openfang-runtime/src/web_search.rs +++ b/crates/openfang-runtime/src/web_search.rs @@ -55,6 +55,7 @@ impl WebSearchEngine { SearchProvider::Tavily => self.search_tavily(query, max_results).await, SearchProvider::Perplexity => self.search_perplexity(query).await, SearchProvider::DuckDuckGo => self.search_duckduckgo(query, max_results).await, + SearchProvider::Searxng => self.search_searxng(query, max_results).await, SearchProvider::Auto => self.search_auto(query, max_results).await, }; @@ -67,7 +68,7 @@ impl WebSearchEngine { } /// Auto-select provider based on available API keys. - /// Priority: Tavily → Brave → Perplexity → DuckDuckGo + /// Priority: Tavily → Brave → Perplexity → Searxng → DuckDuckGo async fn search_auto(&self, query: &str, max_results: usize) -> Result { // Tavily first (AI-agent-native) if resolve_api_key(&self.config.tavily.api_key_env).is_some() { @@ -96,6 +97,15 @@ impl WebSearchEngine { } } + // Searxng fourth (self-hosted, no API key needed) + if !self.config.searxng.url.is_empty() { + debug!("Auto: trying Searxng"); + match self.search_searxng(query, max_results).await { + Ok(result) => return Ok(result), + Err(e) => warn!("Searxng failed, falling back: {e}"), + } + } + // DuckDuckGo always available as zero-config fallback debug!("Auto: falling back to DuckDuckGo"); self.search_duckduckgo(query, max_results).await @@ -313,6 +323,99 @@ impl WebSearchEngine { Ok(output) } + + /// Search via SearXNG self-hosted instance. + async fn search_searxng(&self, query: &str, max_results: usize) -> Result { + if self.config.searxng.url.is_empty() { + return Err("SearXNG URL is not configured".to_string()); + } + + let limit = max_results.min(self.config.searxng.max_results); + + debug!(query, "Searching via SearXNG"); + + let resp = self + .client + .get(format!("{}/search", self.config.searxng.url.trim_end_matches('/'))) + .query(&[ + ("q", query), + ("format", "json"), + ("engines", "general"), + ("limit", &limit.to_string()), + ]) + .header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)") + .send() + .await + .map_err(|e| format!("SearXNG request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("SearXNG API returned {}", resp.status())); + } + + #[derive(serde::Deserialize)] + struct SearxngResponse { + results: Vec, + query: String, + } + + #[derive(serde::Deserialize)] + struct SearxngResult { + url: String, + title: String, + content: Option, + } + + let data: SearxngResponse = resp + .json() + .await + .map_err(|e| format!("SearXNG JSON parse failed: {e}"))?; + + if data.results.is_empty() { + return Err(format!("No results found for '{query}' (SearXNG).")); + } + + #[derive(serde::Serialize)] + struct OutputResult<'a> { + title: &'a str, + url: &'a str, + content: &'a str, + } + + #[derive(serde::Serialize)] + struct Output<'a> { + query: &'a str, + results: Vec>, + } + + let results: Vec = data + .results + .iter() + .map(|r| OutputResult { + title: &r.title, + url: &r.url, + content: r.content.as_deref().unwrap_or(""), + }) + .collect(); + + let output = Output { + query: &data.query, + results, + }; + + serde_json::to_string(&output) + .map_err(|e| format!("Failed to serialize SearXNG results: {e}")) + } + + /// Return all non-Auto search providers (for provider listing/discovery). + pub fn all_providers() -> Vec { + vec![ + SearchProvider::Brave, + SearchProvider::Tavily, + SearchProvider::Perplexity, + SearchProvider::DuckDuckGo, + SearchProvider::Searxng, + ] + } } // --------------------------------------------------------------------------- diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 2c85ae35..2cdd7e59 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -173,7 +173,9 @@ pub enum SearchProvider { Perplexity, /// DuckDuckGo HTML (no API key needed). DuckDuckGo, - /// Auto-select based on available API keys (Tavily → Brave → Perplexity → DuckDuckGo). + /// SearXNG self-hosted search (no API key needed). + Searxng, + /// Auto-select based on available API keys (Tavily → Brave → Perplexity → Searxng → DuckDuckGo). #[default] Auto, } @@ -192,6 +194,8 @@ pub struct WebConfig { pub tavily: TavilySearchConfig, /// Perplexity Search configuration. pub perplexity: PerplexitySearchConfig, + /// SearXNG Search configuration. + pub searxng: SearxngSearchConfig, /// Web fetch configuration. pub fetch: WebFetchConfig, } @@ -204,6 +208,7 @@ impl Default for WebConfig { brave: BraveSearchConfig::default(), tavily: TavilySearchConfig::default(), perplexity: PerplexitySearchConfig::default(), + searxng: SearxngSearchConfig::default(), fetch: WebFetchConfig::default(), } } @@ -281,6 +286,25 @@ impl Default for PerplexitySearchConfig { } } +/// SearXNG self-hosted search configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SearxngSearchConfig { + /// Base URL of the SearXNG instance (e.g., "https://search.example.com"). + pub url: String, + /// Maximum results to return. + pub max_results: usize, +} + +impl Default for SearxngSearchConfig { + fn default() -> Self { + Self { + url: String::new(), + max_results: 5, + } + } +} + /// Web fetch configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -3588,6 +3612,13 @@ impl KernelConfig { )); } } + SearchProvider::Searxng => { + if self.web.searxng.url.is_empty() { + warnings.push( + "Searxng search selected but searxng.url is not configured".to_string(), + ); + } + } SearchProvider::DuckDuckGo | SearchProvider::Auto => {} }