feat: support searxng (#216)

Co-Authored-By: Minghan Zhang <112773885+zmh-program@users.noreply.github.com>
This commit is contained in:
Deng Junhai 2024-06-28 01:56:49 +08:00
parent 04ad6afa98
commit b0a684cada
13 changed files with 357 additions and 56 deletions

View File

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

View File

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

View File

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

View File

@ -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<SystemResponse> {
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<TestWebSearchResponse> {
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: [],

View File

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
@ -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 ?? ""}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] max-w-[60vw] p-0" align={align}>
<PopoverContent
className={cn("w-[320px] max-w-[60vw] p-0", classNameContent)}
align={align}
>
<Command>
<CommandInput placeholder={placeholder} />
{!hideSearchBar && <CommandInput placeholder={placeholder} />}
<CommandEmpty>{t("admin.empty")}</CommandEmpty>
<CommandList>
{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}
</CommandItem>
))}
</CommandList>

View File

@ -76,7 +76,7 @@ export function MultiCombobox({
</PopoverTrigger>
<PopoverContent className="w-[320px] max-w-[60vw] p-0" align={align}>
<Command>
{disabledSearch && <CommandInput placeholder={searchPlaceholder} />}
{!disabledSearch && <CommandInput placeholder={searchPlaceholder} />}
<CommandEmpty>{t("admin.empty")}</CommandEmpty>
<CommandList className={`thin-scrollbar`}>
{valueList.map((key) => (

View File

@ -67,6 +67,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
return (
<Input
{...props}
ref={ref}
className={cn(
"number-input transition",

View File

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

View File

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

View File

@ -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": "招待コード",

View File

@ -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": "Код приглашения",

View File

@ -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<T> = {
data: T;
@ -833,12 +835,20 @@ function Common({ form, data, dispatch, onChange }: CompProps<CommonState>) {
function Search({ data, dispatch, onChange }: CompProps<SearchState>) {
const { t } = useTranslation();
const [search, setSearch] = useState<string>("");
const [searchDialog, setSearchDialog] = useState<boolean>(false);
const [searchResult, setSearchResult] = useState<string>("");
const [searchLoading, setSearchLoading] = useState<boolean>(false);
return (
<Paragraph
title={t("admin.system.search")}
configParagraph={true}
isCollapsed={true}
>
<ParagraphDescription border>
{t("admin.system.searchTip")}
</ParagraphDescription>
<ParagraphItem>
<Label>{t("admin.system.searchEndpoint")}</Label>
<Input
@ -853,28 +863,172 @@ function Search({ data, dispatch, onChange }: CompProps<SearchState>) {
/>
</ParagraphItem>
<ParagraphItem>
<Label>
{t("admin.system.searchQuery")}
<Tips
className={`inline-block`}
content={t("admin.system.searchQueryTip")}
/>
</Label>
<NumberInput
value={data.query}
onValueChange={(value) =>
dispatch({ type: "update:search.query", value })
}
placeholder={`5`}
min={0}
max={50}
<Label>{t("admin.system.searchEngines")}</Label>
<MultiCombobox
value={data.engines}
onChange={(value) => {
dispatch({ type: "update:search.engines", value });
}}
list={[
"google",
"bing",
"duckduckgo",
"qwant",
"brave",
"mojeek",
"arxiv",
"crossref",
"youtube",
"bilibili",
"presearch",
"yahoo",
"wiby",
"seznam",
"goo",
"naver",
"wikidata",
"wikipedia",
"wikimini",
"wikibooks",
"wikiquote",
"wikisource",
"wikispecies",
"wikiversity",
"wikivoyage",
"ask",
"currency",
"yep",
"yacy",
"genius",
"github",
"gitlab",
"gitea.com",
"bitbucket",
"codeberg",
"mdn",
]}
placeholder={t("admin.system.searchEnginesPlaceholder", {
length: (data.engines || []).length,
})}
searchPlaceholder={t("admin.system.searchEnginesSearchPlaceholder")}
/>
</ParagraphItem>
{data.engines.length === 0 && (
<ParagraphDescription border>
{t("admin.system.searchTip")}
{t("admin.system.searchEnginesEmptyTip")}
</ParagraphDescription>
)}
<ParagraphItem>
<Label className={`flex flex-row items-center`}>
{t("admin.system.searchImageProxy")}
<Tips content={t("admin.system.searchImageProxyTip")} />
</Label>
<Switch
checked={data.image_proxy}
onCheckedChange={(value) => {
dispatch({ type: "update:search.image_proxy", value });
}}
/>
</ParagraphItem>
<ParagraphItem>
<Label className={`flex flex-row items-center`}>
{t("admin.system.searchCrop")}
<Tips content={t("admin.system.searchCropTip")} />
</Label>
<Switch
checked={data.crop}
onCheckedChange={(value) => {
dispatch({ type: "update:search.crop", value });
}}
/>
</ParagraphItem>
<ParagraphItem>
<Label>{t("admin.system.searchCropLen")}</Label>
<NumberInput
value={data.crop_len}
onValueChange={(value) =>
dispatch({ type: "update:search.crop_len", value })
}
min={1}
disabled={!data.crop}
/>
</ParagraphItem>
<ParagraphItem>
<Label>{t("admin.system.searchSafeSearch")}</Label>
<Combobox
value={["none", "moderation", "strict"][data.safe_search] || "none"}
onChange={(value) => {
dispatch({
type: "update:search.safe_search",
value: ["none", "moderation", "strict"].indexOf(value),
});
}}
list={["none", "moderation", "strict"]}
listTranslated={`admin.system.searchSafeSearchModes`}
hideSearchBar
/>
</ParagraphItem>
<ParagraphFooter>
<div className={`grow`} />
<Dialog open={searchDialog} onOpenChange={setSearchDialog}>
<DialogTrigger asChild>
<Button variant={`outline`} size={`sm`}>
{t("admin.system.searchTest")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("admin.system.searchTest")}</DialogTitle>
<FlexibleTextarea
placeholder={t("admin.system.searchTestTip")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{(searchLoading || searchResult) && (
<div
className={`mt-2 border rounded-md p-4 flex items-center justify-center flex-col`}
>
{searchLoading ? (
<Loader2 className={`h-4 w-4 animate-spin`} />
) : (
<>
<p className={`text-sm mb-1`}>SearXNG Result</p>
<Textarea value={searchResult} rows={5} readOnly />
</>
)}
</div>
)}
</DialogHeader>
<DialogFooter>
<Button
variant={`outline`}
onClick={() => {
setSearch("");
setSearchDialog(false);
}}
>
{t("admin.cancel")}
</Button>
<Button
variant={`default`}
loading={true}
onClick={async () => {
await onChange();
setSearchResult("");
setSearchLoading(true);
const res = await testWebSearching(search);
if (res.status) setSearchResult(res.result);
toastState(toast, t, res, true);
setSearchLoading(false);
}}
>
{t("admin.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
size={`sm`}
loading={true}

View File

@ -85,7 +85,7 @@ type SystemConfig struct {
General generalState `json:"general" mapstructure:"general"`
Site siteState `json:"site" mapstructure:"site"`
Mail mailState `json:"mail" mapstructure:"mail"`
Search searchState `json:"search" mapstructure:"search"`
Search SearchState `json:"search" mapstructure:"search"`
Common commonState `json:"common" mapstructure:"common"`
}