From 79ca1cda3243c9aba4630c5072ddf5223e3c04da Mon Sep 17 00:00:00 2001 From: beann <1012252+beann@user.noreply.gitee.com> Date: Mon, 30 Mar 2026 18:32:57 +0800 Subject: [PATCH] feat: SearXNG dynamic category support with validation --- crates/openfang-runtime/src/web_search.rs | 64 +++++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/openfang-runtime/src/web_search.rs b/crates/openfang-runtime/src/web_search.rs index cf4613fb..425104a2 100644 --- a/crates/openfang-runtime/src/web_search.rs +++ b/crates/openfang-runtime/src/web_search.rs @@ -55,7 +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::Searxng => self.search_searxng(query, max_results, None).await, SearchProvider::Auto => self.search_auto(query, max_results).await, }; @@ -100,7 +100,7 @@ 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 { + match self.search_searxng(query, max_results, None).await { Ok(result) => return Ok(result), Err(e) => warn!("Searxng failed, falling back: {e}"), } @@ -325,11 +325,32 @@ impl WebSearchEngine { } /// Search via SearXNG self-hosted instance. - async fn search_searxng(&self, query: &str, max_results: usize) -> Result { + async fn search_searxng( + &self, + query: &str, + max_results: usize, + category: Option<&str>, + ) -> Result { if self.config.searxng.url.is_empty() { return Err("SearXNG URL is not configured".to_string()); } + let category = category.unwrap_or("general"); + + // Validate category against SearXNG instance + match self.list_searxng_categories().await { + Ok(cats) => { + if !cats.iter().any(|c| c == category) { + return Err(format!( + "Invalid SearXNG category '{}'. Available: {}", + category, + cats.join(", ") + )); + } + } + Err(e) => warn!("Could not validate SearXNG category: {e}"), + } + let limit = max_results; debug!(query, "Searching via SearXNG"); @@ -340,7 +361,7 @@ impl WebSearchEngine { .query(&[ ("q", query), ("format", "json"), - ("engines", "general"), + ("categories", category), ]) .header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)") .send() @@ -411,6 +432,41 @@ impl WebSearchEngine { .map_err(|e| format!("Failed to serialize SearXNG results: {e}")) } + /// List available search categories from the SearXNG instance. + /// + /// Fetches the `/config` endpoint and returns the list of categories + /// the instance supports (e.g., "general", "images", "news", "videos"). + /// Returns an error if SearXNG URL is not configured or the request fails. + pub async fn list_searxng_categories(&self) -> Result, String> { + if self.config.searxng.url.is_empty() { + return Err("SearXNG URL is not configured".to_string()); + } + + #[derive(serde::Deserialize)] + struct SearxngConfig { + categories: Vec, + } + + let resp = self + .client + .get(format!("{}/config", self.config.searxng.url.trim_end_matches('/'))) + .header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)") + .send() + .await + .map_err(|e| format!("SearXNG config request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("SearXNG config API returned {}", resp.status())); + } + + let data: SearxngConfig = resp + .json() + .await + .map_err(|e| format!("SearXNG config JSON parse failed: {e}"))?; + + Ok(data.categories) + } + /// Return all non-Auto search providers (for provider listing/discovery). pub fn all_providers() -> Vec { vec![