From b0a684cada1ed6818c4505a3d2792a64b3bc34ad Mon Sep 17 00:00:00 2001 From: Deng Junhai Date: Fri, 28 Jun 2024 01:56:49 +0800 Subject: [PATCH] feat: support searxng (#216) Co-Authored-By: Minghan Zhang <112773885+zmh-program@users.noreply.github.com> --- addition/web/call.go | 6 +- addition/web/search.go | 38 ++++- admin/router.go | 4 + app/src/admin/api/system.ts | 45 +++++- app/src/components/ui/combo-box.tsx | 27 +++- app/src/components/ui/multi-combobox.tsx | 2 +- app/src/components/ui/number-input.tsx | 3 +- app/src/resources/i18n/cn.json | 21 ++- app/src/resources/i18n/en.json | 23 ++- app/src/resources/i18n/ja.json | 24 ++- app/src/resources/i18n/ru.json | 24 ++- app/src/routes/admin/System.tsx | 186 +++++++++++++++++++++-- channel/system.go | 2 +- 13 files changed, 353 insertions(+), 52 deletions(-) diff --git a/addition/web/call.go b/addition/web/call.go index f477d3ff..61928789 100644 --- a/addition/web/call.go +++ b/addition/web/call.go @@ -11,7 +11,7 @@ import ( type Hook func(message []globals.Message, token int) (string, error) func toWebSearchingMessage(message []globals.Message) []globals.Message { - data := GenerateSearchResult(message[len(message)-1].Content) + data, _ := GenerateSearchResult(message[len(message)-1].Content) return utils.Insert(message, 0, globals.Message{ Role: globals.System, @@ -35,7 +35,7 @@ func ToChatSearched(instance *conversation.Conversation, restart bool) []globals func ToSearched(enable bool, message []globals.Message) []globals.Message { if enable { return toWebSearchingMessage(message) - } else { - return message } + + return message } diff --git a/addition/web/search.go b/addition/web/search.go index 66223aa0..9f1f567f 100644 --- a/addition/web/search.go +++ b/addition/web/search.go @@ -3,10 +3,14 @@ package web import ( "chat/globals" "chat/utils" + "errors" "fmt" + "net/http" "net/url" "strconv" "strings" + + "github.com/gin-gonic/gin" ) type SearXNGResponse struct { @@ -50,7 +54,7 @@ func formatResponse(data *SearXNGResponse) string { func createURLParams(query string) string { params := url.Values{} - params.Add("q", url.QueryEscape(query)) + params.Add("q", query) params.Add("format", "json") params.Add("safesearch", strconv.Itoa(globals.SearchSafeSearch)) if len(globals.SearchEngines) > 0 { @@ -73,11 +77,13 @@ func createSearXNGRequest(query string) (*SearXNGResponse, error) { return utils.MapToRawStruct[SearXNGResponse](data) } -func GenerateSearchResult(q string) string { +func GenerateSearchResult(q string) (string, error) { res, err := createSearXNGRequest(q) if err != nil { - globals.Warn(fmt.Sprintf("[web] failed to get search result: %s (query: %s)", err.Error(), q)) - return "" + globals.Warn(fmt.Sprintf("[web] failed to get search result: %s (query: %s)", err.Error(), utils.Extract(q, 20, "..."))) + + content := fmt.Sprintf("search failed: %s", err.Error()) + return content, errors.New(content) } content := formatResponse(res) @@ -85,7 +91,27 @@ func GenerateSearchResult(q string) string { if globals.SearchCrop { globals.Debug(fmt.Sprintf("[web] crop search result length %d to %d max", len(content), globals.SearchCropLength)) - return utils.Extract(content, globals.SearchCropLength, "...") + return utils.Extract(content, globals.SearchCropLength, "..."), nil + } + return content, nil +} + +func TestSearch(c *gin.Context) { + // get `query` param from query + query := c.Query("query") + + fmt.Println(query) + + res, err := GenerateSearchResult(query) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "error": err.Error(), + }) + } else { + c.JSON(http.StatusOK, gin.H{ + "status": true, + "result": res, + }) } - return content } diff --git a/admin/router.go b/admin/router.go index 91e8d510..9e603ae4 100644 --- a/admin/router.go +++ b/admin/router.go @@ -1,13 +1,17 @@ package admin import ( + "chat/addition/web" "chat/channel" + "github.com/gin-gonic/gin" ) func Register(app *gin.RouterGroup) { channel.Register(app) + app.GET("/admin/config/test/search", web.TestSearch) + app.GET("/admin/analytics/info", InfoAPI) app.GET("/admin/analytics/model", ModelAnalysisAPI) app.GET("/admin/analytics/request", RequestAnalysisAPI) diff --git a/app/src/admin/api/system.ts b/app/src/admin/api/system.ts index 89d8f8d3..a664512e 100644 --- a/app/src/admin/api/system.ts +++ b/app/src/admin/api/system.ts @@ -2,6 +2,10 @@ import { CommonResponse } from "@/api/common.ts"; import { getErrorMessage } from "@/utils/base.ts"; import axios from "axios"; +export type TestWebSearchResponse = CommonResponse & { + result: string; +}; + export type whiteList = { enabled: boolean; custom: string; @@ -30,7 +34,11 @@ export type MailState = { export type SearchState = { endpoint: string; - query: number; + crop: boolean; + crop_len: number; + engines: string[]; + image_proxy: boolean; + safe_search: number; }; export type SiteState = { @@ -72,10 +80,16 @@ export async function getConfig(): Promise { try { const response = await axios.get("/admin/config/view"); const data = response.data as SystemResponse; - if (data.status) { - data.data && - (data.data.mail.white_list.white_list = - data.data.mail.white_list.white_list || commonWhiteList); + if (data.status && data.data) { + // init system data pre-format + + data.data.mail.white_list.white_list = + data.data.mail.white_list.white_list || commonWhiteList; + data.data.search.engines = data.data.search.engines || []; + data.data.search.crop_len = + data.data.search.crop_len && data.data.search.crop_len > 0 + ? data.data.search.crop_len + : 1000; } return data; @@ -104,6 +118,19 @@ export async function updateRootPassword( } } +export async function testWebSearching( + query: string, +): Promise { + try { + const response = await axios.get( + `/admin/config/test/search?query=${encodeURIComponent(query)}`, + ); + return response.data as TestWebSearchResponse; + } catch (e) { + return { status: false, error: getErrorMessage(e), result: "" }; + } +} + export const commonWhiteList: string[] = [ "gmail.com", "outlook.com", @@ -151,8 +178,12 @@ export const initialSystemState: SystemProps = { }, }, search: { - endpoint: "https://duckduckgo-api.vercel.app", - query: 5, + endpoint: "", + crop: false, + crop_len: 1000, + engines: [], + image_proxy: false, + safe_search: 0, }, common: { article: [], diff --git a/app/src/components/ui/combo-box.tsx b/app/src/components/ui/combo-box.tsx index 3df22bb2..869c3612 100644 --- a/app/src/components/ui/combo-box.tsx +++ b/app/src/components/ui/combo-box.tsx @@ -21,20 +21,26 @@ type ComboBoxProps = { value: string; onChange: (value: string) => void; list: string[]; + listTranslated?: string; placeholder?: string; defaultOpen?: boolean; className?: string; + classNameContent?: string; align?: "start" | "end" | "center" | undefined; + hideSearchBar?: boolean; }; export function Combobox({ value, onChange, list, + listTranslated, placeholder, defaultOpen, className, + classNameContent, align, + hideSearchBar, }: ComboBoxProps) { const { t } = useTranslation(); const [open, setOpen] = React.useState(defaultOpen ?? false); @@ -43,7 +49,7 @@ export function Combobox({ const seq = [...list, value ?? ""].filter((v) => v); const set = new Set(seq); return [...set]; - }, [list]); + }, [list, value]); return ( @@ -54,13 +60,20 @@ export function Combobox({ aria-expanded={open} className={cn("w-[320px] max-w-[60vw] justify-between", className)} > - {value || (placeholder ?? "")} + {value + ? listTranslated + ? t(`${listTranslated}.${value}`) + : value + : placeholder ?? ""} - + - + {!hideSearchBar && } {t("admin.empty")} {valueList.map((key) => ( @@ -68,6 +81,8 @@ export function Combobox({ key={key} value={key} onSelect={() => { + if (key === value) return setOpen(false); + onChange(key); setOpen(false); }} @@ -78,7 +93,7 @@ export function Combobox({ key === value ? "opacity-100" : "opacity-0", )} /> - {key} + {listTranslated ? t(`${listTranslated}.${key}`) : key} ))} @@ -86,4 +101,4 @@ export function Combobox({ ); -} +} \ No newline at end of file diff --git a/app/src/components/ui/multi-combobox.tsx b/app/src/components/ui/multi-combobox.tsx index 63f7acd3..f8478f67 100644 --- a/app/src/components/ui/multi-combobox.tsx +++ b/app/src/components/ui/multi-combobox.tsx @@ -76,7 +76,7 @@ export function MultiCombobox({ - {disabledSearch && } + {!disabledSearch && } {t("admin.empty")} {valueList.map((key) => ( diff --git a/app/src/components/ui/number-input.tsx b/app/src/components/ui/number-input.tsx index 3451b2f1..a1c9f464 100644 --- a/app/src/components/ui/number-input.tsx +++ b/app/src/components/ui/number-input.tsx @@ -67,6 +67,7 @@ const NumberInput = React.forwardRef( return ( ( ); NumberInput.displayName = "NumberInput"; -export { NumberInput }; +export { NumberInput }; \ No newline at end of file diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index 92836ac9..bbc41470 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -718,8 +718,25 @@ "searchEndpoint": "搜索接入点", "searchQuery": "最大搜索结果数", "searchQueryTip": "最大搜索结果数,默认为 5", - "searchTip": "DuckDuckGo 联网搜索接入点,不填则无法正常使用联网功能\nDuckDuckGo API 项目搭建:[duckduckgo-api](https://github.com/binjie09/duckduckgo-api)", - "searchPlaceholder": "DuckDuckGo 接入点 (格式仅需填写 https://example.com)", + "searchCrop": "开启结果截断", + "searchCropTip": "开启结果截断,开启后搜索结果内容的字符数如果超过最大结果字符数,则内容后面会被截断", + "searchCropLen": "最大结果字符数", + "searchEngines": "搜索引擎设置", + "searchEnginesPlaceholder": "已选 {{length}} 个搜索引擎", + "searchEnginesSearchPlaceholder": "请输入搜索引擎名称,如:Google", + "searchEnginesEmptyTip": "设置搜索引擎为空时,默认使用 SearXNG 内默认配置的搜索引擎", + "searchTest": "搜索测试", + "searchTestTip": "搜索测试,输入查询内容进行搜索测试", + "searchSafeSearch": "安全搜索模式", + "searchSafeSearchModes": { + "none": "关闭", + "moderation": "中等", + "strict": "严格" + }, + "searchImageProxy": "开启图片代理", + "searchImageProxyTip": "图片代理,开启后搜索引擎返回的图片将会通过 SearXNG 服务节点代理加载", + "searchTip": "[SearXNG](https://github.com/searxng/searxng) 开源搜索引擎提供联网搜索能力。SearXNG Docker 私有化部署示例:[SearXNG Docker](https://github.com/zmh-program/searxng)", + "searchPlaceholder": "SearXNG 服务接入点 (例如 http://ip:7980)", "closeRegistration": "暂停注册", "closeRegistrationTip": "暂停注册,关闭后新用户将无法注册", "closeRelay": "关闭中转 API", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index da70b227..86277a8d 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -519,7 +519,7 @@ "mailPass": "Password", "searchEndpoint": "Search Endpoint", "searchQuery": "Max Search Results", - "searchTip": "DuckDuckGo network search access point, if not filled in, you will not be able to use the network function normally.\nDuckDuckGo API Project Build: [duckduckgo-api] (https://github.com/binjie09/duckduckgo-api)", + "searchTip": "[SearXNG](https://github.com/searxng/searxng) open source search engine that provides networked search capabilities. SearXNG Docker Privatization Deployment Example: [SearXNG Docker](https://github.com/zmh-program/searxng)", "mailFrom": "Sender", "test": "Test outgoing", "updateRoot": "Change Root Password", @@ -575,14 +575,31 @@ "relayPlan": "Subscription Quota Support Staging API", "relayPlanTip": "Subscription quota supports the transit API, after opening the transit API billing will give priority to the use of user subscription quota\n(Tip: Subscription is a quota of times, the model of billing for tokens may affect the cost)", "searchQueryTip": "Maximum number of search results, default is 5", - "searchPlaceholder": "DuckDuckGo Access Point (Format only https://example.com)", + "searchPlaceholder": "SearXNG Service Access Point (e.g. http://ip: 7980)", "image_store": "Picture storage", "image_storeTip": "Images generated by the OpenAI channel DALL-E will be stored on the server to prevent invalidation of the images", "image_storeNoBackend": "No backend domain configured, cannot enable image storage", "closeRelay": "Turn off Staging API", "closeRelayTip": "Turn off the staging API, the staging API will not be available after turning off", "debugMode": "debugging mode", - "debugModeTip": "Debug mode, after turning on, the log will output detailed request parameters and other logs for troubleshooting" + "debugModeTip": "Debug mode, after turning on, the log will output detailed request parameters and other logs for troubleshooting", + "searchCrop": "Turn on results truncation", + "searchCropTip": "Turn on result truncation, if the number of characters in the search result content exceeds the maximum number of characters, the content will be truncated", + "searchCropLen": "Maximum Result Characters", + "searchEngines": "Search Engine Settings", + "searchEnginesPlaceholder": "{{length}} search engines selected", + "searchEnginesSearchPlaceholder": "Please enter the search engine name, ex: Google", + "searchEnginesEmptyTip": "When the search engine is empty, the default search engine configured in SearXNG is used by default", + "searchSafeSearch": "SafeSearch Mode", + "searchSafeSearchModes": { + "none": "Turn off", + "moderation": "Medium", + "strict": "Demanding" + }, + "searchImageProxy": "Turn on image proxy", + "searchImageProxyTip": "Image proxy, the image returned by the search engine after opening will be loaded through the SearXNG service node proxy", + "searchTest": "Search Quizzes", + "searchTestTip": "Search test, enter the query for search test" }, "user": "Users", "invitation-code": "Invitation Code", diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index f29426b0..a22752bb 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -519,7 +519,7 @@ "mailPass": "パスワード", "searchEndpoint": "アクセスポイントを検索", "searchQuery": "検索結果の最大数", - "searchTip": "DuckDuckGoネットワーク検索アクセスポイントに記入しないと、ネットワーク機能を正常に使用できなくなります。\nDuckDuckGo APIプロジェクトビルド:[ duckduckgo - api ]( https://github.com/binjie09/duckduckgo-api)", + "searchTip": "[SearXNG](https://github.com/searxng/searxng) ネットワーク検索機能を提供するオープンソースの検索エンジン。SearXNG Docker民営化の展開例: [SearXNG Docker](https://github.com/zmh-program/searxng)", "mailFrom": "発信元", "test": "テスト送信", "updateRoot": "ルートパスワードの変更", @@ -575,14 +575,32 @@ "relayPlan": "サブスクリプションクォータサポートステージングAPI", "relayPlanTip": "サブスクリプションクォータはトランジットAPIをサポートしています。トランジットAPI請求を開いた後、ユーザーサブスクリプションクォータの使用が優先されます\n(ヒント:サブスクリプションは時間のクォータであり、トークンの請求モデルはコストに影響する可能性があります)", "searchQueryTip": "検索結果の最大数、デフォルトは5です", - "searchPlaceholder": "DuckDuckGoアクセスポイント(フォーマットのみhttps://example.com )", + "searchPlaceholder": "SearXNGサービスアクセスポイント(例: http :// ip: 7980 )", "image_store": "画像ストレージ", "image_storeTip": "OpenAIチャンネルDALL - Eによって生成された画像は、画像の無効化を防ぐためにサーバーに保存されます", "image_storeNoBackend": "バックエンドドメインが設定されていません。画像ストレージを有効にできません", "closeRelay": "ステージングAPIをオフにする", "closeRelayTip": "ステージングAPIをオフにすると、オフにするとステージングAPIは使用できなくなります", "debugMode": "試験調整モード", - "debugModeTip": "デバッグモード、オンにすると、ログは詳細な要求パラメータとトラブルシューティングのための他のログを出力します" + "debugModeTip": "デバッグモード、オンにすると、ログは詳細な要求パラメータとトラブルシューティングのための他のログを出力します", + "prompt_storeTip": "プロンプトレコードストレージ、開いた後、ユーザーのプロンプトレコードはサーバーに保存されます", + "searchCrop": "結果の切り捨てをオンにする", + "searchCropTip": "結果の切り捨てをオンにすると、検索結果コンテンツの文字数が最大文字数を超えた場合、コンテンツは切り捨てられます", + "searchCropLen": "最大結果文字数", + "searchEngines": "検索エンジン設定", + "searchEnginesPlaceholder": "{{length}}検索エンジンが選択されました", + "searchEnginesSearchPlaceholder": "検索エンジン名を入力してください(例: Google )", + "searchEnginesEmptyTip": "検索エンジンが空の場合、SearXNGで設定されたデフォルトの検索エンジンがデフォルトで使用されます", + "searchSafeSearch": "セーフサーチモード", + "searchSafeSearchModes": { + "none": "閉じる", + "moderation": "ミディアム", + "strict": "厳格" + }, + "searchImageProxy": "画像プロキシをオンにする", + "searchImageProxyTip": "画像プロキシ、開いた後に検索エンジンによって返される画像は、SearXNGサービスノードプロキシを介して読み込まれます", + "searchTest": "クイズを検索", + "searchTestTip": "検索テスト、検索テストのクエリを入力してください" }, "user": "ユーザー管理", "invitation-code": "招待コード", diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index 8b25a2b9..7d1793a8 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -519,7 +519,7 @@ "mailPass": "Пароль", "searchEndpoint": "Конечная точка поиска", "searchQuery": "Максимальное количество результатов поиска", - "searchTip": "Точка доступа к поиску сети DuckDuckGo, если она не заполнена, вы не сможете нормально использовать сетевую функцию.\nСборка проекта API DuckDuckGo: [duckduckgo-api] (https://github.com/binjie09/duckduckgo-api)", + "searchTip": "[SearXNG](https://github.com/searxng/searxng) Поисковая система с открытым исходным кодом, предоставляющая возможности сетевого поиска. Пример развертывания SearXNG Docker Privatization: [SearXNG Docker](https://github.com/zmh-program/searxng)", "mailFrom": "От", "test": "Тест исходящий", "updateRoot": "Изменить корневой пароль", @@ -575,14 +575,32 @@ "relayPlan": "API промежуточной поддержки квот подписки", "relayPlanTip": "Квота подписки поддерживает транзитный API, после открытия транзитного API биллинг будет отдавать приоритет использованию пользовательской квоты подписки\n(Совет: Подписка - это квота раз, модель биллинга для токенов может повлиять на стоимость)", "searchQueryTip": "Максимальное количество результатов поиска, по умолчанию 5", - "searchPlaceholder": "Точка доступа DuckDuckGo (только в формате https://example.com)", + "searchPlaceholder": "Точка доступа к службе SearXNG (например, http://ip: 7980)", "image_store": "Хранение изображений", "image_storeTip": "Изображения, сгенерированные каналом OpenAI DALL-E, будут храниться на сервере, чтобы предотвратить недействительность изображений", "image_storeNoBackend": "Нет настроенного внутреннего домена, невозможно включить хранение изображений", "closeRelay": "Отключить Staging API", "closeRelayTip": "Отключите промежуточный API, промежуточный API будет недоступен после отключения", "debugMode": "Режим отладки", - "debugModeTip": "Режим отладки, после включения журнал выведет подробные параметры запроса и другие журналы для устранения неполадок" + "debugModeTip": "Режим отладки, после включения журнал выведет подробные параметры запроса и другие журналы для устранения неполадок", + "prompt_storeTip": "Оперативное хранение записей, после открытия на сервере будет храниться оперативная запись пользователя", + "searchCrop": "Включить усечение результатов", + "searchCropTip": "Включите усечение результатов, если количество символов в содержимом результатов поиска превышает максимальное количество символов, содержимое будет усечено", + "searchCropLen": "Максимальное количество символов результата", + "searchEngines": "Настройки поисковой системы", + "searchEnginesPlaceholder": "Выбрано поисковых систем: {{length}}", + "searchEnginesSearchPlaceholder": "Введите название поисковой системы, например: Google", + "searchEnginesEmptyTip": "Когда поисковая система пуста, по умолчанию используется поисковая система по умолчанию, настроенная в SearXNG", + "searchSafeSearch": "Безопасный режим поиска", + "searchSafeSearchModes": { + "none": "закрыть", + "moderation": "Среднее", + "strict": "Строгие" + }, + "searchImageProxy": "Включить прокси-сервер изображений", + "searchImageProxyTip": "Image proxy, изображение, возвращаемое поисковой системой после открытия, будет загружено через прокси сервисного узла SearXNG", + "searchTest": "Поиск викторины", + "searchTestTip": "Поиск теста, введите запрос для поиска теста" }, "user": "Управление пользователями", "invitation-code": "Код приглашения", diff --git a/app/src/routes/admin/System.tsx b/app/src/routes/admin/System.tsx index ed374616..ef226124 100644 --- a/app/src/routes/admin/System.tsx +++ b/app/src/routes/admin/System.tsx @@ -35,6 +35,7 @@ import { setConfig, SiteState, SystemProps, + testWebSearching, updateRootPassword, } from "@/admin/api/system.ts"; import { useEffectAsync } from "@/utils/hook.ts"; @@ -51,8 +52,8 @@ import { } from "@/components/ui/dialog.tsx"; import { DialogTitle } from "@radix-ui/react-dialog"; import Require from "@/components/Require.tsx"; -import { PencilLine, RotateCw, Save, Settings2 } from "lucide-react"; -import { FlexibleTextarea } from "@/components/ui/textarea.tsx"; +import { Loader2, PencilLine, RotateCw, Save, Settings2 } from "lucide-react"; +import { FlexibleTextarea, Textarea } from "@/components/ui/textarea.tsx"; import Tips from "@/components/Tips.tsx"; import { cn } from "@/components/ui/lib/utils.ts"; import { Switch } from "@/components/ui/switch.tsx"; @@ -62,6 +63,7 @@ import { useChannelModels } from "@/admin/hook.tsx"; import { useSelector } from "react-redux"; import { selectSupportModels } from "@/store/chat.ts"; import { JSONEditorProvider } from "@/components/EditorProvider.tsx"; +import { Combobox } from "@/components/ui/combo-box.tsx"; type CompProps = { data: T; @@ -833,12 +835,20 @@ function Common({ form, data, dispatch, onChange }: CompProps) { function Search({ data, dispatch, onChange }: CompProps) { const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const [searchDialog, setSearchDialog] = useState(false); + const [searchResult, setSearchResult] = useState(""); + const [searchLoading, setSearchLoading] = useState(false); + return ( + + {t("admin.system.searchTip")} + ) { /> - + {data.engines.length === 0 && ( + + {t("admin.system.searchEnginesEmptyTip")} + + )} + + + { + dispatch({ type: "update:search.image_proxy", value }); + }} + /> + + + + { + dispatch({ type: "update:search.crop", value }); + }} + /> + + + - dispatch({ type: "update:search.query", value }) + dispatch({ type: "update:search.crop_len", value }) } - placeholder={`5`} - min={0} - max={50} + min={1} + disabled={!data.crop} + /> + + + + { + dispatch({ + type: "update:search.safe_search", + value: ["none", "moderation", "strict"].indexOf(value), + }); + }} + list={["none", "moderation", "strict"]} + listTranslated={`admin.system.searchSafeSearchModes`} + hideSearchBar /> - - {t("admin.system.searchTip")} -
+ + + + + + + {t("admin.system.searchTest")} + setSearch(e.target.value)} + /> + {(searchLoading || searchResult) && ( +
+ {searchLoading ? ( + + ) : ( + <> +

SearXNG Result

+