diff --git a/README.md b/README.md index cf0424fe2..ee3f5e0d1 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Open source and easy to deploy locally. ### â„šī¸ **External Sources** - Search Engines (Tavily, LinkUp) +- SearxNG (self-hosted instances) - Slack - Linear - Jira @@ -303,4 +304,3 @@ For detailed contribution guidelines, please see our [CONTRIBUTING.md](CONTRIBUT --- --- - diff --git a/surfsense_backend/alembic/versions/26_add_searxng_connector_enum.py b/surfsense_backend/alembic/versions/26_add_searxng_connector_enum.py new file mode 100644 index 000000000..ca6ac2b55 --- /dev/null +++ b/surfsense_backend/alembic/versions/26_add_searxng_connector_enum.py @@ -0,0 +1,42 @@ +"""Add SearxNG connector enum value + +Revision ID: 26 +Revises: 25 +Create Date: 2025-01-18 00:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "26" +down_revision: str | None = "25" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Safely add SEARXNG_API to searchsourceconnectortype enum.""" + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + WHERE t.typname = 'searchsourceconnectortype' AND e.enumlabel = 'SEARXNG_API' + ) THEN + ALTER TYPE searchsourceconnectortype ADD VALUE 'SEARXNG_API'; + END IF; + END + $$; + """ + ) + + +def downgrade() -> None: + """Downgrade not supported for enum edits.""" + pass + diff --git a/surfsense_backend/app/agents/researcher/nodes.py b/surfsense_backend/app/agents/researcher/nodes.py index fe869c265..2b113785a 100644 --- a/surfsense_backend/app/agents/researcher/nodes.py +++ b/surfsense_backend/app/agents/researcher/nodes.py @@ -1033,6 +1033,30 @@ async def fetch_relevant_documents( } ) + elif connector == "SEARXNG_API": + ( + source_object, + searx_chunks, + ) = await connector_service.search_searxng( + user_query=reformulated_query, + user_id=user_id, + search_space_id=search_space_id, + top_k=top_k, + ) + + if source_object: + all_sources.append(source_object) + all_raw_documents.extend(searx_chunks) + + if streaming_service and writer: + writer( + { + "yield_value": streaming_service.format_terminal_info_delta( + f"🌐 Found {len(searx_chunks)} SearxNG results related to your query" + ) + } + ) + elif connector == "LINKUP_API": linkup_mode = "standard" diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index e476ed8e5..bcb6388da 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -55,6 +55,7 @@ class DocumentType(str, Enum): class SearchSourceConnectorType(str, Enum): SERPER_API = "SERPER_API" # NOT IMPLEMENTED YET : DON'T REMEMBER WHY : MOST PROBABLY BECAUSE WE NEED TO CRAWL THE RESULTS RETURNED BY IT TAVILY_API = "TAVILY_API" + SEARXNG_API = "SEARXNG_API" LINKUP_API = "LINKUP_API" SLACK_CONNECTOR = "SLACK_CONNECTOR" NOTION_CONNECTOR = "NOTION_CONNECTOR" diff --git a/surfsense_backend/app/services/connector_service.py b/surfsense_backend/app/services/connector_service.py index 83659a5c4..0017fabaf 100644 --- a/surfsense_backend/app/services/connector_service.py +++ b/surfsense_backend/app/services/connector_service.py @@ -1,6 +1,8 @@ import asyncio from typing import Any +from urllib.parse import urljoin +import httpx from linkup import LinkupClient from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession @@ -372,6 +374,192 @@ class ConnectorService: "sources": [], }, [] + async def search_searxng( + self, + user_query: str, + user_id: str, + search_space_id: int, + top_k: int = 20, + ) -> tuple: + """ + Search using a configured SearxNG instance and return both sources and documents. + """ + searx_connector = await self.get_connector_by_type( + user_id, SearchSourceConnectorType.SEARXNG_API, search_space_id + ) + + if not searx_connector: + return { + "id": 11, + "name": "SearxNG Search", + "type": "SEARXNG_API", + "sources": [], + }, [] + + config = searx_connector.config or {} + host = config.get("SEARXNG_HOST") + + if not host: + print("SearxNG connector is missing SEARXNG_HOST configuration") + return { + "id": 11, + "name": "SearxNG Search", + "type": "SEARXNG_API", + "sources": [], + }, [] + + api_key = config.get("SEARXNG_API_KEY") + engines = config.get("SEARXNG_ENGINES") + categories = config.get("SEARXNG_CATEGORIES") + language = config.get("SEARXNG_LANGUAGE") + safesearch = config.get("SEARXNG_SAFESEARCH") + + def _parse_bool(value: Any, default: bool = True) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + return True + if lowered in {"false", "0", "no", "off"}: + return False + return default + + verify_ssl = _parse_bool(config.get("SEARXNG_VERIFY_SSL", True)) + + safesearch_value: int | None = None + if isinstance(safesearch, str): + safesearch_clean = safesearch.strip() + if safesearch_clean.isdigit(): + safesearch_value = int(safesearch_clean) + elif isinstance(safesearch, (int, float)): + safesearch_value = int(safesearch) + + if safesearch_value is not None and not (0 <= safesearch_value <= 2): + safesearch_value = None + + def _format_list(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + value = value.strip() + return value or None + if isinstance(value, (list, tuple, set)): + cleaned = [str(item).strip() for item in value if str(item).strip()] + return ",".join(cleaned) if cleaned else None + return str(value) + + params: dict[str, Any] = { + "q": user_query, + "format": "json", + "language": language or "", + "limit": max(1, min(top_k, 50)), + } + + engines_param = _format_list(engines) + if engines_param: + params["engines"] = engines_param + + categories_param = _format_list(categories) + if categories_param: + params["categories"] = categories_param + + if safesearch_value is not None: + params["safesearch"] = safesearch_value + + if not params.get("language"): + params.pop("language") + + headers = {"Accept": "application/json"} + if api_key: + headers["X-API-KEY"] = api_key + + searx_endpoint = urljoin(host if host.endswith("/") else f"{host}/", "search") + + try: + async with httpx.AsyncClient(timeout=20.0, verify=verify_ssl) as client: + response = await client.get( + searx_endpoint, + params=params, + headers=headers, + ) + response.raise_for_status() + except httpx.HTTPError as exc: + print(f"Error searching with SearxNG: {exc!s}") + return { + "id": 11, + "name": "SearxNG Search", + "type": "SEARXNG_API", + "sources": [], + }, [] + + try: + data = response.json() + except ValueError: + print("Failed to decode JSON response from SearxNG") + return { + "id": 11, + "name": "SearxNG Search", + "type": "SEARXNG_API", + "sources": [], + }, [] + + searx_results = data.get("results", []) + if not searx_results: + return { + "id": 11, + "name": "SearxNG Search", + "type": "SEARXNG_API", + "sources": [], + }, [] + + sources_list: list[dict[str, Any]] = [] + documents: list[dict[str, Any]] = [] + + async with self.counter_lock: + for result in searx_results: + description = result.get("content") or result.get("snippet") or "" + if len(description) > 160: + description = f"{description[:157]}..." + + source = { + "id": self.source_id_counter, + "title": result.get("title", "SearxNG Result"), + "description": description, + "url": result.get("url", ""), + } + sources_list.append(source) + + metadata = { + "url": result.get("url", ""), + "engines": result.get("engines", []), + "category": result.get("category"), + "source": "SEARXNG_API", + } + + document = { + "chunk_id": self.source_id_counter, + "content": description or result.get("content", ""), + "score": result.get("score", 0.0), + "document": { + "id": self.source_id_counter, + "title": result.get("title", "SearxNG Result"), + "document_type": "SEARXNG_API", + "metadata": metadata, + }, + } + documents.append(document) + self.source_id_counter += 1 + + result_object = { + "id": 11, + "name": "SearxNG Search", + "type": "SEARXNG_API", + "sources": sources_list, + } + + return result_object, documents + async def search_slack( self, user_query: str, diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py index 437d23b55..7e29860ba 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -424,6 +424,22 @@ def validate_connector_config( connector_rules = { "SERPER_API": {"required": ["SERPER_API_KEY"], "validators": {}}, "TAVILY_API": {"required": ["TAVILY_API_KEY"], "validators": {}}, + "SEARXNG_API": { + "required": ["SEARXNG_HOST"], + "optional": [ + "SEARXNG_API_KEY", + "SEARXNG_ENGINES", + "SEARXNG_CATEGORIES", + "SEARXNG_LANGUAGE", + "SEARXNG_SAFESEARCH", + "SEARXNG_VERIFY_SSL", + ], + "validators": { + "SEARXNG_HOST": lambda: validate_url_field( + "SEARXNG_HOST", "SearxNG" + ) + }, + }, "LINKUP_API": {"required": ["LINKUP_API_KEY"], "validators": {}}, "SLACK_CONNECTOR": {"required": ["SLACK_BOT_TOKEN"], "validators": {}}, "NOTION_CONNECTOR": { @@ -484,10 +500,21 @@ def validate_connector_config( if not rules: return config # Unknown connector type, pass through - # Validate required keys match exactly - if set(config.keys()) != set(rules["required"]): + required_keys = set(rules["required"]) + optional_keys = set(rules.get("optional", [])) + config_keys = set(config.keys()) + + # Validate that no unexpected keys are present + if not config_keys.issubset(required_keys | optional_keys): + allowed_keys = list(required_keys | optional_keys) raise ValueError( - f"For {connector_type_str} connector type, config must only contain these keys: {rules['required']}" + f"For {connector_type_str} connector type, config may only contain these keys: {allowed_keys}" + ) + + # Validate that all required keys are present + if not required_keys.issubset(config_keys): + raise ValueError( + f"For {connector_type_str} connector type, config must include these keys: {sorted(required_keys)}" ) # Apply custom validators first (these check format before emptiness) diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx index a8bb9cfe8..1c5aa049d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx @@ -45,6 +45,13 @@ const connectorCategories: ConnectorCategory[] = [ icon: getConnectorIcon(EnumConnectorName.TAVILY_API, "h-6 w-6"), status: "available", }, + { + id: "searxng", + title: "SearxNG", + description: "Use your own SearxNG meta-search instance for web results.", + icon: getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6"), + status: "available", + }, { id: "linkup-api", title: "Linkup API", diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/searxng/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/searxng/page.tsx new file mode 100644 index 000000000..ca736a8cd --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/searxng/page.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as z from "zod"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { EnumConnectorName } from "@/contracts/enums/connector"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; + +const searxngFormSchema = z.object({ + name: z.string().min(3, { + message: "Connector name must be at least 3 characters.", + }), + host: z + .string({ required_error: "Host is required." }) + .url({ message: "Enter a valid SearxNG host URL (e.g. https://searxng.example.org)." }), + api_key: z.string().optional(), + engines: z.string().optional(), + categories: z.string().optional(), + language: z.string().optional(), + safesearch: z + .string() + .regex(/^[0-2]?$/, { message: "SafeSearch must be 0, 1, or 2." }) + .optional(), + verify_ssl: z.boolean().default(true), +}); + +type SearxngFormValues = z.infer; + +const parseCommaSeparated = (value?: string | null) => { + if (!value) return undefined; + const items = value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return items.length > 0 ? items : undefined; +}; + +export default function SearxngConnectorPage() { + const router = useRouter(); + const params = useParams(); + const searchSpaceId = params.search_space_id as string; + const [isSubmitting, setIsSubmitting] = useState(false); + const { createConnector } = useSearchSourceConnectors(); + + const form = useForm({ + resolver: zodResolver(searxngFormSchema), + defaultValues: { + name: "SearxNG Connector", + host: "", + api_key: "", + engines: "", + categories: "", + language: "", + safesearch: "", + verify_ssl: true, + }, + }); + + const onSubmit = async (values: SearxngFormValues) => { + setIsSubmitting(true); + try { + const config: Record = { + SEARXNG_HOST: values.host.trim(), + }; + + const apiKey = values.api_key?.trim(); + if (apiKey) config.SEARXNG_API_KEY = apiKey; + + const engines = parseCommaSeparated(values.engines); + if (engines) config.SEARXNG_ENGINES = engines; + + const categories = parseCommaSeparated(values.categories); + if (categories) config.SEARXNG_CATEGORIES = categories; + + const language = values.language?.trim(); + if (language) config.SEARXNG_LANGUAGE = language; + + const safesearch = values.safesearch?.trim(); + if (safesearch) { + const parsed = Number(safesearch); + if (!Number.isNaN(parsed)) { + config.SEARXNG_SAFESEARCH = parsed; + } + } + + // Include verify flag only when disabled to keep config minimal + if (values.verify_ssl === false) { + config.SEARXNG_VERIFY_SSL = false; + } + + await createConnector( + { + name: values.name, + connector_type: EnumConnectorName.SEARXNG_API, + config, + is_indexable: false, + last_indexed_at: null, + }, + parseInt(searchSpaceId) + ); + + toast.success("SearxNG connector created successfully!"); + router.push(`/dashboard/${searchSpaceId}/connectors`); + } catch (error) { + console.error("Error creating SearxNG connector:", error); + toast.error(error instanceof Error ? error.message : "Failed to create connector"); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + +
+
+
+ {getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6")} +
+
+

Connect SearxNG

+

+ Bring your self-hosted SearxNG meta-search engine into SurfSense. +

+
+
+
+ + + + + Connect SearxNG + + Integrate SurfSense with any SearxNG instance to broaden your search coverage while + preserving privacy and control. + + + + + + SearxNG Instance Required + + You need access to a running SearxNG instance. Refer to the{" "} + + SearxNG installation guide + {" "} + for setup instructions. If your instance requires an API key, include it below. + + + +
+ + ( + + Connector Name + + + + A friendly name to identify this connector. + + + )} + /> + + ( + + SearxNG Host + + + + + Provide the full base URL to your SearxNG instance. Include the protocol + (http/https). + + + + )} + /> + + ( + + API Key (optional) + + + + + Leave empty if your SearxNG instance does not enforce API keys. + + + + )} + /> + +
+ ( + + Engines (optional) + + + + Comma-separated list to target specific engines. + + + )} + /> + + ( + + Categories (optional) + + + + Comma-separated list of SearxNG categories. + + + )} + /> +
+ +
+ ( + + Preferred Language (optional) + + + + + IETF language tag (e.g. en, en-US). Leave blank to inherit defaults. + + + + )} + /> + + ( + + SafeSearch Level (optional) + + + + + Set 0, 1, or 2 to adjust SafeSearch filtering. Leave blank to use the instance + default. + + + + )} + /> +
+ + ( + +
+ Verify SSL Certificates + + Disable only when connecting to instances with self-signed certificates. + +
+ + + +
+ )} + /> + + + + + + +
+
+
+
+ ); +} diff --git a/surfsense_web/components/chat/SourceDetailSheet.tsx b/surfsense_web/components/chat/SourceDetailSheet.tsx index 4f7d129e4..138d004db 100644 --- a/surfsense_web/components/chat/SourceDetailSheet.tsx +++ b/surfsense_web/components/chat/SourceDetailSheet.tsx @@ -52,7 +52,8 @@ export function SourceDetailSheet({ const [summaryOpen, setSummaryOpen] = useState(false); // Check if this is a source type that should render directly from node - const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API"; + const isDirectRenderSource = + sourceType === "TAVILY_API" || sourceType === "LINKUP_API" || sourceType === "SEARXNG_API"; useEffect(() => { if (open && chunkId && !isDirectRenderSource) { @@ -108,7 +109,7 @@ export function SourceDetailSheet({ )} - {/* Direct render for TAVILY_API and LINKUP_API */} + {/* Direct render for web search providers */} {isDirectRenderSource && (
diff --git a/surfsense_web/components/editConnector/types.ts b/surfsense_web/components/editConnector/types.ts index 4e91ef305..c069972ba 100644 --- a/surfsense_web/components/editConnector/types.ts +++ b/surfsense_web/components/editConnector/types.ts @@ -30,6 +30,13 @@ export const editConnectorSchema = z.object({ NOTION_INTEGRATION_TOKEN: z.string().optional(), SERPER_API_KEY: z.string().optional(), TAVILY_API_KEY: z.string().optional(), + SEARXNG_HOST: z.string().optional(), + SEARXNG_API_KEY: z.string().optional(), + SEARXNG_ENGINES: z.string().optional(), + SEARXNG_CATEGORIES: z.string().optional(), + SEARXNG_LANGUAGE: z.string().optional(), + SEARXNG_SAFESEARCH: z.string().optional(), + SEARXNG_VERIFY_SSL: z.string().optional(), LINEAR_API_KEY: z.string().optional(), LINKUP_API_KEY: z.string().optional(), DISCORD_BOT_TOKEN: z.string().optional(), diff --git a/surfsense_web/contracts/enums/connector.ts b/surfsense_web/contracts/enums/connector.ts index a49314e2e..39c6fcfcf 100644 --- a/surfsense_web/contracts/enums/connector.ts +++ b/surfsense_web/contracts/enums/connector.ts @@ -1,6 +1,7 @@ export enum EnumConnectorName { SERPER_API = "SERPER_API", TAVILY_API = "TAVILY_API", + SEARXNG_API = "SEARXNG_API", LINKUP_API = "LINKUP_API", SLACK_CONNECTOR = "SLACK_CONNECTOR", NOTION_CONNECTOR = "NOTION_CONNECTOR", diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx index f34a10398..1d17f49e1 100644 --- a/surfsense_web/contracts/enums/connectorIcons.tsx +++ b/surfsense_web/contracts/enums/connectorIcons.tsx @@ -32,6 +32,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas return ; case EnumConnectorName.TAVILY_API: return ; + case EnumConnectorName.SEARXNG_API: + return ; case EnumConnectorName.SLACK_CONNECTOR: return ; case EnumConnectorName.NOTION_CONNECTOR: diff --git a/surfsense_web/hooks/use-connector-edit-page.ts b/surfsense_web/hooks/use-connector-edit-page.ts index fe90db3c8..ea6a3fe10 100644 --- a/surfsense_web/hooks/use-connector-edit-page.ts +++ b/surfsense_web/hooks/use-connector-edit-page.ts @@ -16,6 +16,38 @@ import { useSearchSourceConnectors, } from "@/hooks/use-search-source-connectors"; +const normalizeListInput = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter((item) => item.length > 0); + } + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + return []; +}; + +const arraysEqual = (a: string[], b: string[]): boolean => { + if (a.length !== b.length) return false; + return a.every((value, index) => value === b[index]); +}; + +const normalizeBoolean = (value: unknown): boolean | null => { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const lowered = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(lowered)) return true; + if (["false", "0", "no", "off"].includes(lowered)) return false; + } + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + } + return null; +}; + export function useConnectorEditPage(connectorId: number, searchSpaceId: string) { const router = useRouter(); const { @@ -48,6 +80,13 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string) NOTION_INTEGRATION_TOKEN: "", SERPER_API_KEY: "", TAVILY_API_KEY: "", + SEARXNG_HOST: "", + SEARXNG_API_KEY: "", + SEARXNG_ENGINES: "", + SEARXNG_CATEGORIES: "", + SEARXNG_LANGUAGE: "", + SEARXNG_SAFESEARCH: "", + SEARXNG_VERIFY_SSL: "", LINEAR_API_KEY: "", DISCORD_BOT_TOKEN: "", CONFLUENCE_BASE_URL: "", @@ -74,6 +113,23 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string) NOTION_INTEGRATION_TOKEN: config.NOTION_INTEGRATION_TOKEN || "", SERPER_API_KEY: config.SERPER_API_KEY || "", TAVILY_API_KEY: config.TAVILY_API_KEY || "", + SEARXNG_HOST: config.SEARXNG_HOST || "", + SEARXNG_API_KEY: config.SEARXNG_API_KEY || "", + SEARXNG_ENGINES: Array.isArray(config.SEARXNG_ENGINES) + ? config.SEARXNG_ENGINES.join(", ") + : config.SEARXNG_ENGINES || "", + SEARXNG_CATEGORIES: Array.isArray(config.SEARXNG_CATEGORIES) + ? config.SEARXNG_CATEGORIES.join(", ") + : config.SEARXNG_CATEGORIES || "", + SEARXNG_LANGUAGE: config.SEARXNG_LANGUAGE || "", + SEARXNG_SAFESEARCH: + config.SEARXNG_SAFESEARCH !== undefined && config.SEARXNG_SAFESEARCH !== null + ? String(config.SEARXNG_SAFESEARCH) + : "", + SEARXNG_VERIFY_SSL: + config.SEARXNG_VERIFY_SSL !== undefined && config.SEARXNG_VERIFY_SSL !== null + ? String(config.SEARXNG_VERIFY_SSL) + : "", LINEAR_API_KEY: config.LINEAR_API_KEY || "", LINKUP_API_KEY: config.LINKUP_API_KEY || "", DISCORD_BOT_TOKEN: config.DISCORD_BOT_TOKEN || "", @@ -238,6 +294,93 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string) newConfig = { TAVILY_API_KEY: formData.TAVILY_API_KEY }; } break; + case "SEARXNG_API": { + const host = (formData.SEARXNG_HOST || "").trim(); + if (!host) { + toast.error("SearxNG host is required."); + setIsSaving(false); + return; + } + + const candidateConfig: Record = { SEARXNG_HOST: host }; + let hasChanges = host !== (originalConfig.SEARXNG_HOST || "").trim(); + + const apiKey = (formData.SEARXNG_API_KEY || "").trim(); + const originalApiKey = (originalConfig.SEARXNG_API_KEY || "").trim(); + if (apiKey !== originalApiKey) { + candidateConfig.SEARXNG_API_KEY = apiKey || null; + hasChanges = true; + } + + const newEngines = normalizeListInput(formData.SEARXNG_ENGINES || ""); + const originalEngines = normalizeListInput(originalConfig.SEARXNG_ENGINES); + if (!arraysEqual(newEngines, originalEngines)) { + candidateConfig.SEARXNG_ENGINES = newEngines; + hasChanges = true; + } + + const newCategories = normalizeListInput(formData.SEARXNG_CATEGORIES || ""); + const originalCategories = normalizeListInput(originalConfig.SEARXNG_CATEGORIES); + if (!arraysEqual(newCategories, originalCategories)) { + candidateConfig.SEARXNG_CATEGORIES = newCategories; + hasChanges = true; + } + + const language = (formData.SEARXNG_LANGUAGE || "").trim(); + const originalLanguage = (originalConfig.SEARXNG_LANGUAGE || "").trim(); + if (language !== originalLanguage) { + candidateConfig.SEARXNG_LANGUAGE = language || null; + hasChanges = true; + } + + const safesearchRaw = (formData.SEARXNG_SAFESEARCH || "").trim(); + const originalSafesearch = originalConfig.SEARXNG_SAFESEARCH; + if (safesearchRaw) { + const parsed = Number(safesearchRaw); + if ( + Number.isNaN(parsed) || + !Number.isInteger(parsed) || + parsed < 0 || + parsed > 2 + ) { + toast.error("SearxNG SafeSearch must be 0, 1, or 2."); + setIsSaving(false); + return; + } + if (parsed !== Number(originalSafesearch)) { + candidateConfig.SEARXNG_SAFESEARCH = parsed; + hasChanges = true; + } + } else if (originalSafesearch !== undefined && originalSafesearch !== null) { + candidateConfig.SEARXNG_SAFESEARCH = null; + hasChanges = true; + } + + const verifyRaw = (formData.SEARXNG_VERIFY_SSL || "").trim().toLowerCase(); + const originalVerifyBool = normalizeBoolean(originalConfig.SEARXNG_VERIFY_SSL); + if (verifyRaw) { + let parsedBool: boolean | null = null; + if (["true", "1", "yes", "on"].includes(verifyRaw)) parsedBool = true; + else if (["false", "0", "no", "off"].includes(verifyRaw)) parsedBool = false; + if (parsedBool === null) { + toast.error("SearxNG SSL verification must be true or false."); + setIsSaving(false); + return; + } + if (parsedBool !== originalVerifyBool) { + candidateConfig.SEARXNG_VERIFY_SSL = parsedBool; + hasChanges = true; + } + } else if (originalVerifyBool !== null) { + candidateConfig.SEARXNG_VERIFY_SSL = null; + hasChanges = true; + } + + if (hasChanges) { + newConfig = candidateConfig; + } + break; + } case "LINEAR_CONNECTOR": if (formData.LINEAR_API_KEY !== originalConfig.LINEAR_API_KEY) { @@ -367,6 +510,33 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string) editForm.setValue("SERPER_API_KEY", newlySavedConfig.SERPER_API_KEY || ""); } else if (connector.connector_type === "TAVILY_API") { editForm.setValue("TAVILY_API_KEY", newlySavedConfig.TAVILY_API_KEY || ""); + } else if (connector.connector_type === "SEARXNG_API") { + editForm.setValue("SEARXNG_HOST", newlySavedConfig.SEARXNG_HOST || ""); + editForm.setValue("SEARXNG_API_KEY", newlySavedConfig.SEARXNG_API_KEY || ""); + editForm.setValue( + "SEARXNG_ENGINES", + normalizeListInput(newlySavedConfig.SEARXNG_ENGINES).join(", ") + ); + editForm.setValue( + "SEARXNG_CATEGORIES", + normalizeListInput(newlySavedConfig.SEARXNG_CATEGORIES).join(", ") + ); + editForm.setValue( + "SEARXNG_LANGUAGE", + newlySavedConfig.SEARXNG_LANGUAGE || "" + ); + editForm.setValue( + "SEARXNG_SAFESEARCH", + newlySavedConfig.SEARXNG_SAFESEARCH === null || + newlySavedConfig.SEARXNG_SAFESEARCH === undefined + ? "" + : String(newlySavedConfig.SEARXNG_SAFESEARCH) + ); + const verifyValue = normalizeBoolean(newlySavedConfig.SEARXNG_VERIFY_SSL); + editForm.setValue( + "SEARXNG_VERIFY_SSL", + verifyValue === null ? "" : String(verifyValue) + ); } else if (connector.connector_type === "LINEAR_CONNECTOR") { editForm.setValue("LINEAR_API_KEY", newlySavedConfig.LINEAR_API_KEY || ""); } else if (connector.connector_type === "LINKUP_API") { diff --git a/surfsense_web/hooks/use-connectors.ts b/surfsense_web/hooks/use-connectors.ts index 9e9f2dc53..63ee7b372 100644 --- a/surfsense_web/hooks/use-connectors.ts +++ b/surfsense_web/hooks/use-connectors.ts @@ -23,7 +23,7 @@ export const getConnectorTypeDisplay = (type: string): string => { const typeMap: Record = { SERPER_API: "Serper API", TAVILY_API: "Tavily API", - // Add other connector types here as needed + SEARXNG_API: "SearxNG", }; return typeMap[type] || type; }; diff --git a/surfsense_web/lib/connectors/utils.ts b/surfsense_web/lib/connectors/utils.ts index 798204d94..5b542d32d 100644 --- a/surfsense_web/lib/connectors/utils.ts +++ b/surfsense_web/lib/connectors/utils.ts @@ -3,6 +3,7 @@ export const getConnectorTypeDisplay = (type: string): string => { const typeMap: Record = { SERPER_API: "Serper API", TAVILY_API: "Tavily API", + SEARXNG_API: "SearxNG", SLACK_CONNECTOR: "Slack", NOTION_CONNECTOR: "Notion", GITHUB_CONNECTOR: "GitHub",