Merge pull request #392 from Aki-07/feature/add-searxng-connector

Add Searxng connector
This commit is contained in:
Rohan Verma 2025-10-13 13:41:37 -07:00 committed by GitHub
commit 08661e686c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 838 additions and 7 deletions

View file

@ -62,6 +62,7 @@ Open source and easy to deploy locally.
### **External Sources** ### **External Sources**
- Search Engines (Tavily, LinkUp) - Search Engines (Tavily, LinkUp)
- SearxNG (self-hosted instances)
- Slack - Slack
- Linear - Linear
- Jira - Jira
@ -303,4 +304,3 @@ For detailed contribution guidelines, please see our [CONTRIBUTING.md](CONTRIBUT
--- ---
--- ---

View file

@ -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

View file

@ -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": elif connector == "LINKUP_API":
linkup_mode = "standard" linkup_mode = "standard"

View file

@ -55,6 +55,7 @@ class DocumentType(str, Enum):
class SearchSourceConnectorType(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 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" TAVILY_API = "TAVILY_API"
SEARXNG_API = "SEARXNG_API"
LINKUP_API = "LINKUP_API" LINKUP_API = "LINKUP_API"
SLACK_CONNECTOR = "SLACK_CONNECTOR" SLACK_CONNECTOR = "SLACK_CONNECTOR"
NOTION_CONNECTOR = "NOTION_CONNECTOR" NOTION_CONNECTOR = "NOTION_CONNECTOR"

View file

@ -1,6 +1,8 @@
import asyncio import asyncio
from typing import Any from typing import Any
from urllib.parse import urljoin
import httpx
from linkup import LinkupClient from linkup import LinkupClient
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -372,6 +374,192 @@ class ConnectorService:
"sources": [], "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( async def search_slack(
self, self,
user_query: str, user_query: str,

View file

@ -424,6 +424,22 @@ def validate_connector_config(
connector_rules = { connector_rules = {
"SERPER_API": {"required": ["SERPER_API_KEY"], "validators": {}}, "SERPER_API": {"required": ["SERPER_API_KEY"], "validators": {}},
"TAVILY_API": {"required": ["TAVILY_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": {}}, "LINKUP_API": {"required": ["LINKUP_API_KEY"], "validators": {}},
"SLACK_CONNECTOR": {"required": ["SLACK_BOT_TOKEN"], "validators": {}}, "SLACK_CONNECTOR": {"required": ["SLACK_BOT_TOKEN"], "validators": {}},
"NOTION_CONNECTOR": { "NOTION_CONNECTOR": {
@ -484,10 +500,21 @@ def validate_connector_config(
if not rules: if not rules:
return config # Unknown connector type, pass through return config # Unknown connector type, pass through
# Validate required keys match exactly required_keys = set(rules["required"])
if set(config.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( 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) # Apply custom validators first (these check format before emptiness)

View file

@ -45,6 +45,13 @@ const connectorCategories: ConnectorCategory[] = [
icon: getConnectorIcon(EnumConnectorName.TAVILY_API, "h-6 w-6"), icon: getConnectorIcon(EnumConnectorName.TAVILY_API, "h-6 w-6"),
status: "available", 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", id: "linkup-api",
title: "Linkup API", title: "Linkup API",

View file

@ -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<typeof searxngFormSchema>;
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<SearxngFormValues>({
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<string, unknown> = {
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 (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect SearxNG</h1>
<p className="text-muted-foreground">
Bring your self-hosted SearxNG meta-search engine into SurfSense.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect SearxNG</CardTitle>
<CardDescription>
Integrate SurfSense with any SearxNG instance to broaden your search coverage while
preserving privacy and control.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>SearxNG Instance Required</AlertTitle>
<AlertDescription>
You need access to a running SearxNG instance. Refer to the{" "}
<a
href="https://docs.searxng.org/admin/installation-docker.html"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
SearxNG installation guide
</a>{" "}
for setup instructions. If your instance requires an API key, include it below.
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My SearxNG Connector" {...field} />
</FormControl>
<FormDescription>A friendly name to identify this connector.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>SearxNG Host</FormLabel>
<FormControl>
<Input placeholder="https://searxng.example.org" {...field} />
</FormControl>
<FormDescription>
Provide the full base URL to your SearxNG instance. Include the protocol
(http/https).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key (optional)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter API key if your instance requires one"
{...field}
/>
</FormControl>
<FormDescription>
Leave empty if your SearxNG instance does not enforce API keys.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="engines"
render={({ field }) => (
<FormItem>
<FormLabel>Engines (optional)</FormLabel>
<FormControl>
<Input placeholder="google,bing,duckduckgo" {...field} />
</FormControl>
<FormDescription>Comma-separated list to target specific engines.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categories"
render={({ field }) => (
<FormItem>
<FormLabel>Categories (optional)</FormLabel>
<FormControl>
<Input placeholder="general,it,science" {...field} />
</FormControl>
<FormDescription>Comma-separated list of SearxNG categories.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Preferred Language (optional)</FormLabel>
<FormControl>
<Input placeholder="en-US" {...field} />
</FormControl>
<FormDescription>
IETF language tag (e.g. en, en-US). Leave blank to inherit defaults.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="safesearch"
render={({ field }) => (
<FormItem>
<FormLabel>SafeSearch Level (optional)</FormLabel>
<FormControl>
<Input placeholder="0 (off), 1 (moderate), 2 (strict)" {...field} />
</FormControl>
<FormDescription>
Set 0, 1, or 2 to adjust SafeSearch filtering. Leave blank to use the instance
default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="verify_ssl"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div>
<FormLabel>Verify SSL Certificates</FormLabel>
<FormDescription>
Disable only when connecting to instances with self-signed certificates.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<CardFooter className="flex justify-end px-0">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect SearxNG
</>
)}
</Button>
</CardFooter>
</form>
</Form>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View file

@ -52,7 +52,8 @@ export function SourceDetailSheet({
const [summaryOpen, setSummaryOpen] = useState(false); const [summaryOpen, setSummaryOpen] = useState(false);
// Check if this is a source type that should render directly from node // 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(() => { useEffect(() => {
if (open && chunkId && !isDirectRenderSource) { if (open && chunkId && !isDirectRenderSource) {
@ -108,7 +109,7 @@ export function SourceDetailSheet({
</div> </div>
)} )}
{/* Direct render for TAVILY_API and LINKUP_API */} {/* Direct render for web search providers */}
{isDirectRenderSource && ( {isDirectRenderSource && (
<ScrollArea className="h-[calc(100vh-10rem)]"> <ScrollArea className="h-[calc(100vh-10rem)]">
<div className="px-6 py-4"> <div className="px-6 py-4">

View file

@ -30,6 +30,13 @@ export const editConnectorSchema = z.object({
NOTION_INTEGRATION_TOKEN: z.string().optional(), NOTION_INTEGRATION_TOKEN: z.string().optional(),
SERPER_API_KEY: z.string().optional(), SERPER_API_KEY: z.string().optional(),
TAVILY_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(), LINEAR_API_KEY: z.string().optional(),
LINKUP_API_KEY: z.string().optional(), LINKUP_API_KEY: z.string().optional(),
DISCORD_BOT_TOKEN: z.string().optional(), DISCORD_BOT_TOKEN: z.string().optional(),

View file

@ -1,6 +1,7 @@
export enum EnumConnectorName { export enum EnumConnectorName {
SERPER_API = "SERPER_API", SERPER_API = "SERPER_API",
TAVILY_API = "TAVILY_API", TAVILY_API = "TAVILY_API",
SEARXNG_API = "SEARXNG_API",
LINKUP_API = "LINKUP_API", LINKUP_API = "LINKUP_API",
SLACK_CONNECTOR = "SLACK_CONNECTOR", SLACK_CONNECTOR = "SLACK_CONNECTOR",
NOTION_CONNECTOR = "NOTION_CONNECTOR", NOTION_CONNECTOR = "NOTION_CONNECTOR",

View file

@ -32,6 +32,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <Link {...iconProps} />; return <Link {...iconProps} />;
case EnumConnectorName.TAVILY_API: case EnumConnectorName.TAVILY_API:
return <IconWorldWww {...iconProps} />; return <IconWorldWww {...iconProps} />;
case EnumConnectorName.SEARXNG_API:
return <Globe {...iconProps} />;
case EnumConnectorName.SLACK_CONNECTOR: case EnumConnectorName.SLACK_CONNECTOR:
return <IconBrandSlack {...iconProps} />; return <IconBrandSlack {...iconProps} />;
case EnumConnectorName.NOTION_CONNECTOR: case EnumConnectorName.NOTION_CONNECTOR:

View file

@ -16,6 +16,38 @@ import {
useSearchSourceConnectors, useSearchSourceConnectors,
} from "@/hooks/use-search-source-connectors"; } 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) { export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
const router = useRouter(); const router = useRouter();
const { const {
@ -48,6 +80,13 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
NOTION_INTEGRATION_TOKEN: "", NOTION_INTEGRATION_TOKEN: "",
SERPER_API_KEY: "", SERPER_API_KEY: "",
TAVILY_API_KEY: "", TAVILY_API_KEY: "",
SEARXNG_HOST: "",
SEARXNG_API_KEY: "",
SEARXNG_ENGINES: "",
SEARXNG_CATEGORIES: "",
SEARXNG_LANGUAGE: "",
SEARXNG_SAFESEARCH: "",
SEARXNG_VERIFY_SSL: "",
LINEAR_API_KEY: "", LINEAR_API_KEY: "",
DISCORD_BOT_TOKEN: "", DISCORD_BOT_TOKEN: "",
CONFLUENCE_BASE_URL: "", CONFLUENCE_BASE_URL: "",
@ -74,6 +113,23 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
NOTION_INTEGRATION_TOKEN: config.NOTION_INTEGRATION_TOKEN || "", NOTION_INTEGRATION_TOKEN: config.NOTION_INTEGRATION_TOKEN || "",
SERPER_API_KEY: config.SERPER_API_KEY || "", SERPER_API_KEY: config.SERPER_API_KEY || "",
TAVILY_API_KEY: config.TAVILY_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 || "", LINEAR_API_KEY: config.LINEAR_API_KEY || "",
LINKUP_API_KEY: config.LINKUP_API_KEY || "", LINKUP_API_KEY: config.LINKUP_API_KEY || "",
DISCORD_BOT_TOKEN: config.DISCORD_BOT_TOKEN || "", 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 }; newConfig = { TAVILY_API_KEY: formData.TAVILY_API_KEY };
} }
break; break;
case "SEARXNG_API": {
const host = (formData.SEARXNG_HOST || "").trim();
if (!host) {
toast.error("SearxNG host is required.");
setIsSaving(false);
return;
}
const candidateConfig: Record<string, any> = { 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": case "LINEAR_CONNECTOR":
if (formData.LINEAR_API_KEY !== originalConfig.LINEAR_API_KEY) { 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 || ""); editForm.setValue("SERPER_API_KEY", newlySavedConfig.SERPER_API_KEY || "");
} else if (connector.connector_type === "TAVILY_API") { } else if (connector.connector_type === "TAVILY_API") {
editForm.setValue("TAVILY_API_KEY", newlySavedConfig.TAVILY_API_KEY || ""); 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") { } else if (connector.connector_type === "LINEAR_CONNECTOR") {
editForm.setValue("LINEAR_API_KEY", newlySavedConfig.LINEAR_API_KEY || ""); editForm.setValue("LINEAR_API_KEY", newlySavedConfig.LINEAR_API_KEY || "");
} else if (connector.connector_type === "LINKUP_API") { } else if (connector.connector_type === "LINKUP_API") {

View file

@ -23,7 +23,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
SERPER_API: "Serper API", SERPER_API: "Serper API",
TAVILY_API: "Tavily API", TAVILY_API: "Tavily API",
// Add other connector types here as needed SEARXNG_API: "SearxNG",
}; };
return typeMap[type] || type; return typeMap[type] || type;
}; };

View file

@ -3,6 +3,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
SERPER_API: "Serper API", SERPER_API: "Serper API",
TAVILY_API: "Tavily API", TAVILY_API: "Tavily API",
SEARXNG_API: "SearxNG",
SLACK_CONNECTOR: "Slack", SLACK_CONNECTOR: "Slack",
NOTION_CONNECTOR: "Notion", NOTION_CONNECTOR: "Notion",
GITHUB_CONNECTOR: "GitHub", GITHUB_CONNECTOR: "GitHub",