mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
Merge pull request #392 from Aki-07/feature/add-searxng-connector
Add Searxng connector
This commit is contained in:
commit
08661e686c
15 changed files with 838 additions and 7 deletions
|
|
@ -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
|
|||
|
||||
---
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct render for TAVILY_API and LINKUP_API */}
|
||||
{/* Direct render for web search providers */}
|
||||
{isDirectRenderSource && (
|
||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
||||
<div className="px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
return <Link {...iconProps} />;
|
||||
case EnumConnectorName.TAVILY_API:
|
||||
return <IconWorldWww {...iconProps} />;
|
||||
case EnumConnectorName.SEARXNG_API:
|
||||
return <Globe {...iconProps} />;
|
||||
case EnumConnectorName.SLACK_CONNECTOR:
|
||||
return <IconBrandSlack {...iconProps} />;
|
||||
case EnumConnectorName.NOTION_CONNECTOR:
|
||||
|
|
|
|||
|
|
@ -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<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":
|
||||
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") {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
|||
const typeMap: Record<string, string> = {
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
// Add other connector types here as needed
|
||||
SEARXNG_API: "SearxNG",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
|||
const typeMap: Record<string, string> = {
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
SEARXNG_API: "SearxNG",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue